diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9701df0e..eb872b22 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -79,7 +79,7 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni | Area | Convention | Example | | ------------------ | ------------------------------------------ | -------------------------------------------------------- | | **Backend** | Use case with `execute()` method | `class GetUploadUrlUseCase { async execute(gameId) {} }` | -| **Imports** | Path aliases only (no relative imports) | `import { User } from "@domain/entities"` | +| **Imports** | Prefer path aliases for cross-module imports; allow relative imports for same-directory files | `import { User } from "@domain/entities"` | | **DI** | Constructor injection of repositories | `constructor(private saveRepo: SaveRepository)` | | **Validation** | TypeBox schemas in interfaces layer | TypeScript strict mode enabled | | **Tauri Commands** | `#[tauri::command] pub async fn name() {}` | Must register in `ipc/handlers.rs` | @@ -90,9 +90,10 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni ### Import Rules -- Use path aliases: `@domain`, `@application`, `@infrastructure` -- Import from index files: `from "@domain"` or `from "@domain/entities"` -- Never use relative paths beyond same directory: `import "./foo"` → `import from "foo.ts"` +- Use path aliases for imports that cross module boundaries: `@domain`, `@application`, `@infrastructure` (preferred for clarity and tooling). Example: `import { User } from "@domain/entities";` +- Use relative imports only for files that live in the same directory (sibling files). Example: `import { Component } from "./Component";` +- Import from index files when convenient: `import { Thing } from "@domain";` or `import { Thing } from "./index";` +- Avoid mixing alias and relative styles inside the same module; choose one convention per directory to keep imports consistent. When in doubt, prefer the alias for cross-module boundaries and the relative form for local (same-directory) references. --- @@ -132,12 +133,12 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni | Issue | Impact | Prevention | | --------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------- | | Unregistered Tauri commands | Command fails silently | Always update `src-tauri/src/ipc/handlers.rs` `generate_handler![]` macro | -| Missing capabilities.json | Permission denied silently | Add capability for any new Tauri feature | +| Missing capabilities.json | Permission denied silently | Add capability for any new Tauri feature. When implementing Rust features that touch the filesystem, network, or OS processes, prompt the user to ensure the required capability is added to `tauri.conf.json` or `capabilities.json` (if you cannot edit it directly). | | S3 presigned URL throttling | 503 SlowDown at 500+ concurrent requests | Use concurrency limits (e.g., `PRESIGN_CONCURRENCY=50`) | | API Gateway auth cache | 401 leaked between requests | Use Host as identity source, disable TTL cache | | Non-owned types in async Tauri commands | Won't compile | Always use `String`, `Vec`, never `&str` or `&[T]` | | Token secret mismatch | Auth fails across Lambda invocations | Store in AWS SSM Parameter Store or external vault | -| Relative imports mixing | TypeScript confusion | Always use path aliases; never mix relative/absolute | +| Relative imports mixing | TypeScript confusion | Prefer path aliases for inter-module imports; allow relative imports for same-directory files. Avoid mixing styles within the same module to reduce confusion. | ### Concurrency & AWS Limits @@ -150,14 +151,28 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni ## Testing -**Important**: No test framework currently configured — Manual testing only. +**Important**: No test framework is currently configured in this repository — testing is manual by default. If you (or a collaborator) choose to add automated tests later, you must update project configuration, add test scripts, and adjust CI before running them. -When implementing tests: +Recommended frameworks to use when adding tests (only apply these after configuring the runner): -- Backend: **vitest** + **supertest** (Fastify) for use cases, Lambda handlers +- Backend: **vitest** + **supertest** (Fastify) for use cases and Lambda handlers - Desktop: **vitest** + **React Testing Library** for components -- Rust: Built-in `#[tokio::test]` with mocking -- Focus on edge cases: S3 throttling, token expiry, WebSocket lifecycle, file sync race conditions +- Rust: built-in `#[tokio::test]` with mocking + +- Focus on edge cases: S3 presign throttling, token expiry, WebSocket lifecycle, file sync race conditions + +If a user or automation requests to run tests but no test framework is configured, politely inform them that no test runner is present and suggest these options: + +- Configure a test runner (example commands to install a minimal Vitest setup): + +```bash +# Install vitest and supertest for backend +bun add -d vitest supertest + +# Add a test script to package.json: "test": "vitest" +``` + +- Or perform manual validation steps and list what to check (end-to-end flows, critical edge cases) until automated tests are added. --- diff --git a/apps/api/src/application/use-cases/CreateTransferSessionUseCase.ts b/apps/api/src/application/use-cases/CreateTransferSessionUseCase.ts new file mode 100644 index 00000000..9b1043f2 --- /dev/null +++ b/apps/api/src/application/use-cases/CreateTransferSessionUseCase.ts @@ -0,0 +1,96 @@ +import crypto from "crypto"; +import type { CloudInviteRepository } from "@domain/ports/CloudInviteRepository"; +import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository"; + +export interface CreateTransferSessionInput { + requesterUserId: string; + targetUserId: string; + targetDeviceId: string; + gameKey: string; + manifestHash: string; +} + +export interface TransferSessionResult { + sessionId: string; + token: string; + expiresAt: string; + targetUserId: string; + targetDeviceId: string; + gameKey: string; + manifestHash: string; +} + +const SESSION_TTL_MS = 5 * 60 * 1000; + +export class CreateTransferSessionUseCase { + constructor( + private readonly inventoryRepository: GameInventoryRepository, + private readonly cloudInviteRepository: CloudInviteRepository + ) {} + + async execute(input: CreateTransferSessionInput): Promise { + const requesterId = input.requesterUserId.trim(); + const targetUserId = input.targetUserId.trim(); + const targetDeviceId = input.targetDeviceId.trim(); + const gameKey = input.gameKey.trim(); + const manifestHash = input.manifestHash.trim(); + + if (!requesterId || !targetUserId || !targetDeviceId || !gameKey || !manifestHash) { + throw new Error("Invalid transfer session input"); + } + + await this.assertSameCloud(requesterId, targetUserId); + + const record = await this.inventoryRepository.getDeviceRecord(targetUserId, targetDeviceId); + if (!record?.sharingEnabled) { + throw new Error("Target device inventory not found or sharing disabled"); + } + + const game = record.games.find((g) => g.gameKey === gameKey && g.status === "verified"); + if (!game || game.manifestHash !== manifestHash) { + throw new Error("Game manifest mismatch on target device"); + } + + const sessionId = crypto.randomUUID(); + const token = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + + await this.inventoryRepository.putTransferSession({ + sessionId, + token, + requesterUserId: requesterId, + targetUserId, + targetDeviceId, + gameKey, + manifestHash, + expiresAt, + }); + + return { + sessionId, + token, + expiresAt, + targetUserId, + targetDeviceId, + gameKey, + manifestHash, + }; + } + + private async assertSameCloud(requesterId: string, targetUserId: string): Promise { + if (requesterId === targetUserId) { + throw new Error("Cannot transfer from yourself"); + } + + const requesterHosts = await this.cloudInviteRepository.listMembershipsForMember(requesterId); + const active = requesterHosts.find((m) => m.active); + const hostUserId = active?.hostUserId ?? requesterId; + + const members = await this.cloudInviteRepository.listMembershipsForHost(hostUserId); + const peerIds = new Set([hostUserId, ...members.filter((m) => m.active).map((m) => m.memberUserId)]); + + if (!peerIds.has(targetUserId)) { + throw new Error("Target user is not in your cloud"); + } + } +} diff --git a/apps/api/src/application/use-cases/ListGameProvidersUseCase.ts b/apps/api/src/application/use-cases/ListGameProvidersUseCase.ts new file mode 100644 index 00000000..1df6dd65 --- /dev/null +++ b/apps/api/src/application/use-cases/ListGameProvidersUseCase.ts @@ -0,0 +1,51 @@ +import type { CloudInviteRepository } from "@domain/ports/CloudInviteRepository"; +import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository"; +import type { GameProviderDevice } from "@domain/entities/GameInventory"; + +export interface ListGameProvidersInput { + requesterUserId: string; + gameKey: string; +} + +export interface ListGameProvidersResult { + gameKey: string; + providers: GameProviderDevice[]; +} + +export class ListGameProvidersUseCase { + constructor( + private readonly inventoryRepository: GameInventoryRepository, + private readonly cloudInviteRepository: CloudInviteRepository + ) {} + + async execute(input: ListGameProvidersInput): Promise { + const requesterId = input.requesterUserId.trim(); + const gameKey = input.gameKey.trim(); + if (!requesterId || !gameKey) { + throw new Error("requesterUserId and gameKey are required"); + } + + const hostUserId = await this.resolveActiveHostUserId(requesterId); + const providers = await this.inventoryRepository.listProvidersForGame(hostUserId, gameKey, requesterId); + + const enriched = await Promise.all( + providers.map(async (p) => { + const record = await this.inventoryRepository.getDeviceRecord(p.userId, p.deviceId); + const game = record?.games.find((g) => g.gameKey === gameKey); + return { + ...p, + files: game?.files ?? [], + }; + }) + ); + + return { gameKey, providers: enriched }; + } + + private async resolveActiveHostUserId(userId: string): Promise { + const memberships = await this.cloudInviteRepository.listMembershipsForMember(userId); + const active = memberships.find((m) => m.active); + if (active) return active.hostUserId; + return userId; + } +} diff --git a/apps/api/src/application/use-cases/ListPendingTransferSessionsUseCase.ts b/apps/api/src/application/use-cases/ListPendingTransferSessionsUseCase.ts new file mode 100644 index 00000000..2f0a3273 --- /dev/null +++ b/apps/api/src/application/use-cases/ListPendingTransferSessionsUseCase.ts @@ -0,0 +1,12 @@ +import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository"; +import type { TransferSessionRecord } from "@domain/ports/GameInventoryRepository"; + +export class ListPendingTransferSessionsUseCase { + constructor(private readonly repository: GameInventoryRepository) {} + + async execute(targetDeviceId: string): Promise { + const deviceId = targetDeviceId.trim(); + if (!deviceId) throw new Error("deviceId is required"); + return this.repository.listPendingTransferSessions(deviceId); + } +} diff --git a/apps/api/src/application/use-cases/PublishDeviceInventoryUseCase.ts b/apps/api/src/application/use-cases/PublishDeviceInventoryUseCase.ts new file mode 100644 index 00000000..046e20f5 --- /dev/null +++ b/apps/api/src/application/use-cases/PublishDeviceInventoryUseCase.ts @@ -0,0 +1,20 @@ +import type { GameInventoryRepository, PublishDeviceInventoryInput } from "@domain/ports/GameInventoryRepository"; + +export class PublishDeviceInventoryUseCase { + constructor(private readonly repository: GameInventoryRepository) {} + + async execute(input: PublishDeviceInventoryInput): Promise { + if (!input.userId.trim() || !input.deviceId.trim()) { + throw new Error("userId and deviceId are required"); + } + if (!input.sharingEnabled) { + await this.repository.deleteDeviceInventory(input.userId, input.deviceId); + return; + } + const verified = input.games.filter((g) => g.status === "verified" && g.gameKey.trim()); + if (verified.length !== input.games.length) { + throw new Error("Only verified game entries may be published"); + } + await this.repository.putDeviceInventory({ ...input, games: verified }); + } +} diff --git a/apps/api/src/application/use-cases/RecordInventoryHeartbeatUseCase.ts b/apps/api/src/application/use-cases/RecordInventoryHeartbeatUseCase.ts new file mode 100644 index 00000000..b60e80c2 --- /dev/null +++ b/apps/api/src/application/use-cases/RecordInventoryHeartbeatUseCase.ts @@ -0,0 +1,9 @@ +import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository"; + +export class RecordInventoryHeartbeatUseCase { + constructor(private readonly repository: GameInventoryRepository) {} + + async execute(userId: string, deviceId: string, appVersion?: string): Promise { + await this.repository.recordHeartbeat(userId.trim(), deviceId.trim(), appVersion); + } +} diff --git a/apps/api/src/domain/entities/GameInventory.ts b/apps/api/src/domain/entities/GameInventory.ts new file mode 100644 index 00000000..6270454a --- /dev/null +++ b/apps/api/src/domain/entities/GameInventory.ts @@ -0,0 +1,57 @@ +export interface InventoryFileEntry { + relativePath: string; + size: number; + hash: string; +} + +export interface SourcesArchiveEntry { + jobId: string; + relativePath: string; + size: number; + hash: string; + verifiedAt: string; +} + +export interface GameInventoryEntry { + gameKey: string; + displayName: string; + status: string; + payloadKind: string; + totalBytes: number; + fileCount: number; + manifestHash: string; + verifiedAt: string; + files: InventoryFileEntry[]; + sourcesArchive?: SourcesArchiveEntry; +} + +export interface DeviceInventoryRecord { + deviceId: string; + userId: string; + deviceName: string; + manifestVersion: number; + contentHash: string; + updatedAt: string; + sharingEnabled: boolean; + lastSeenAt?: string; + appVersion?: string; + games: GameInventoryEntry[]; +} + +export interface GameProviderDevice { + userId: string; + deviceId: string; + deviceName: string; + totalBytes: number; + payloadKind: string; + manifestHash: string; + verifiedAt: string; + lastSeenAt?: string; + files?: InventoryFileEntry[]; +} + +export interface GameIndexEntry { + version: 1; + gameKey: string; + devices: GameProviderDevice[]; +} diff --git a/apps/api/src/domain/ports/GameInventoryRepository.ts b/apps/api/src/domain/ports/GameInventoryRepository.ts new file mode 100644 index 00000000..bdd5e845 --- /dev/null +++ b/apps/api/src/domain/ports/GameInventoryRepository.ts @@ -0,0 +1,34 @@ +import type { DeviceInventoryRecord, GameInventoryEntry, GameProviderDevice } from "@domain/entities/GameInventory"; + +export interface PublishDeviceInventoryInput { + userId: string; + deviceId: string; + deviceName: string; + manifestVersion: number; + contentHash: string; + updatedAt: string; + sharingEnabled: boolean; + games: GameInventoryEntry[]; +} + +export interface TransferSessionRecord { + sessionId: string; + token: string; + requesterUserId: string; + targetUserId: string; + targetDeviceId: string; + gameKey: string; + manifestHash: string; + expiresAt: string; +} + +export interface GameInventoryRepository { + putDeviceInventory(input: PublishDeviceInventoryInput): Promise; + deleteDeviceInventory(userId: string, deviceId: string): Promise; + recordHeartbeat(userId: string, deviceId: string, appVersion?: string): Promise; + listProvidersForGame(hostUserId: string, gameKey: string, excludeUserId?: string): Promise; + getDeviceRecord(userId: string, deviceId: string): Promise; + putTransferSession(record: TransferSessionRecord): Promise; + listPendingTransferSessions(targetDeviceId: string): Promise; + consumeTransferSession(sessionId: string): Promise; +} diff --git a/apps/api/src/infrastructure/persistence/S3GameInventoryRepository.ts b/apps/api/src/infrastructure/persistence/S3GameInventoryRepository.ts new file mode 100644 index 00000000..c88ddf9e --- /dev/null +++ b/apps/api/src/infrastructure/persistence/S3GameInventoryRepository.ts @@ -0,0 +1,281 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; +import type { DeviceInventoryRecord, GameInventoryEntry, GameProviderDevice } from "@domain/entities/GameInventory"; +import type { CloudInviteRepository } from "@domain/ports/CloudInviteRepository"; +import type { + GameInventoryRepository, + PublishDeviceInventoryInput, + TransferSessionRecord, +} from "@domain/ports/GameInventoryRepository"; + +function isNotFound(err: unknown): boolean { + const e = err as { name?: string; $metadata?: { httpStatusCode?: number } }; + return e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function encodeGameKey(gameKey: string): string { + return encodeURIComponent(gameKey.replace(/:/g, "_")); +} + +export class S3GameInventoryRepository implements GameInventoryRepository { + constructor( + private readonly s3: S3Client, + private readonly bucketName: string, + private readonly cloudInviteRepository: CloudInviteRepository + ) {} + + private deviceKey(userId: string, deviceId: string): string { + return `game-inventory/devices/${userId}/${deviceId}.json`; + } + + private indexKey(hostUserId: string, gameKey: string): string { + return `game-inventory/index/${hostUserId}/by-game/${encodeGameKey(gameKey)}.json`; + } + + private async getJsonOrNull(key: string): Promise { + try { + const res = await this.s3.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); + const raw = await res.Body?.transformToString(); + if (!raw?.trim()) return null; + return JSON.parse(raw) as T; + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + } + + private async putJson(key: string, value: unknown): Promise { + await this.s3.send( + new PutObjectCommand({ + Bucket: this.bucketName, + Key: key, + Body: JSON.stringify(value), + ContentType: "application/json", + }) + ); + } + + private async deleteKey(key: string): Promise { + try { + await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: key })); + } catch (err) { + if (!isNotFound(err)) throw err; + } + } + + private async listKeys(prefix: string): Promise { + const out: string[] = []; + let continuationToken: string | undefined; + do { + const res = await this.s3.send( + new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: prefix, + ContinuationToken: continuationToken, + }) + ); + for (const item of res.Contents ?? []) { + if (item.Key) out.push(item.Key); + } + continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined; + } while (continuationToken); + return out; + } + + private async resolveHostUserIds(userId: string): Promise { + const hosts = new Set([userId]); + const asMember = await this.cloudInviteRepository.listMembershipsForMember(userId); + for (const m of asMember) { + if (m.active) hosts.add(m.hostUserId); + } + const asHost = await this.cloudInviteRepository.listMembershipsForHost(userId); + for (const m of asHost) { + if (m.active) hosts.add(userId); + } + return [...hosts]; + } + + async getDeviceRecord(userId: string, deviceId: string): Promise { + return this.getJsonOrNull(this.deviceKey(userId, deviceId)); + } + + private deviceToProvider(d: DeviceInventoryRecord, game: GameInventoryEntry): GameProviderDevice { + return { + userId: d.userId, + deviceId: d.deviceId, + deviceName: d.deviceName, + totalBytes: game.totalBytes, + payloadKind: game.payloadKind, + manifestHash: game.manifestHash, + verifiedAt: game.verifiedAt, + lastSeenAt: d.lastSeenAt, + }; + } + + private async removeDeviceFromIndex( + hostUserId: string, + gameKey: string, + userId: string, + deviceId: string + ): Promise { + const key = this.indexKey(hostUserId, gameKey); + const index = await this.getJsonOrNull<{ version: 1; gameKey: string; devices: GameProviderDevice[] }>(key); + if (!index) return; + const devices = index.devices.filter((d) => !(d.userId === userId && d.deviceId === deviceId)); + if (devices.length === 0) { + await this.deleteKey(key); + } else { + await this.putJson(key, { version: 1, gameKey, devices }); + } + } + + private async addDeviceToIndex( + hostUserId: string, + record: DeviceInventoryRecord, + game: GameInventoryEntry + ): Promise { + const key = this.indexKey(hostUserId, game.gameKey); + const index = (await this.getJsonOrNull<{ version: 1; gameKey: string; devices: GameProviderDevice[] }>(key)) ?? { + version: 1 as const, + gameKey: game.gameKey, + devices: [], + }; + const provider = this.deviceToProvider(record, game); + const devices = index.devices.filter((d) => !(d.userId === record.userId && d.deviceId === record.deviceId)); + devices.push(provider); + await this.putJson(key, { version: 1, gameKey: game.gameKey, devices }); + } + + async putDeviceInventory(input: PublishDeviceInventoryInput): Promise { + const verified = input.games.filter((g) => g.status === "verified"); + const previous = await this.getDeviceRecord(input.userId, input.deviceId); + const previousKeys = new Set(previous?.games.map((g) => g.gameKey) ?? []); + + const record: DeviceInventoryRecord = { + deviceId: input.deviceId, + userId: input.userId, + deviceName: input.deviceName, + manifestVersion: input.manifestVersion, + contentHash: input.contentHash, + updatedAt: input.updatedAt, + sharingEnabled: input.sharingEnabled, + lastSeenAt: nowIso(), + games: verified, + }; + + await this.putJson(this.deviceKey(input.userId, input.deviceId), record); + + const hostIds = await this.resolveHostUserIds(input.userId); + const newKeys = new Set(verified.map((g) => g.gameKey)); + + for (const oldKey of previousKeys) { + if (!newKeys.has(oldKey)) { + for (const hostId of hostIds) { + await this.removeDeviceFromIndex(hostId, oldKey, input.userId, input.deviceId); + } + } + } + + if (!input.sharingEnabled) { + for (const key of newKeys) { + for (const hostId of hostIds) { + await this.removeDeviceFromIndex(hostId, key, input.userId, input.deviceId); + } + } + return; + } + + for (const game of verified) { + for (const hostId of hostIds) { + await this.addDeviceToIndex(hostId, record, game); + } + } + } + + async deleteDeviceInventory(userId: string, deviceId: string): Promise { + const previous = await this.getDeviceRecord(userId, deviceId); + await this.deleteKey(this.deviceKey(userId, deviceId)); + if (!previous) return; + + const hostIds = await this.resolveHostUserIds(userId); + for (const game of previous.games) { + for (const hostId of hostIds) { + await this.removeDeviceFromIndex(hostId, game.gameKey, userId, deviceId); + } + } + } + + async recordHeartbeat(userId: string, deviceId: string, appVersion?: string): Promise { + const record = await this.getDeviceRecord(userId, deviceId); + if (!record) return; + record.lastSeenAt = nowIso(); + if (appVersion?.trim()) record.appVersion = appVersion.trim(); + await this.putJson(this.deviceKey(userId, deviceId), record); + } + + async listProvidersForGame( + hostUserId: string, + gameKey: string, + excludeUserId?: string + ): Promise { + const key = this.indexKey(hostUserId, gameKey); + const index = await this.getJsonOrNull<{ version: 1; devices: GameProviderDevice[] }>(key); + if (!index?.devices?.length) return []; + return index.devices.filter((d) => !excludeUserId || d.userId !== excludeUserId); + } + + private transferSessionKey(sessionId: string): string { + return `game-inventory/sessions/${sessionId}.json`; + } + + private pendingSessionsPrefix(deviceId: string): string { + return `game-inventory/pending/${deviceId}/`; + } + + async putTransferSession(record: TransferSessionRecord): Promise { + await this.putJson(this.transferSessionKey(record.sessionId), record); + await this.putJson(`${this.pendingSessionsPrefix(record.targetDeviceId)}${record.sessionId}.json`, { + sessionId: record.sessionId, + }); + } + + async listPendingTransferSessions(targetDeviceId: string): Promise { + const prefix = this.pendingSessionsPrefix(targetDeviceId); + const keys = await this.listKeys(prefix); + const out: TransferSessionRecord[] = []; + const now = Date.now(); + for (const key of keys) { + const stub = await this.getJsonOrNull<{ sessionId: string }>(key); + if (!stub?.sessionId) continue; + const session = await this.getJsonOrNull(this.transferSessionKey(stub.sessionId)); + if (!session) { + await this.deleteKey(key); + continue; + } + const exp = Date.parse(session.expiresAt); + if (!Number.isFinite(exp) || exp < now) { + await this.deleteKey(key); + await this.deleteKey(this.transferSessionKey(session.sessionId)); + continue; + } + out.push(session); + } + return out; + } + + async consumeTransferSession(sessionId: string): Promise { + const session = await this.getJsonOrNull(this.transferSessionKey(sessionId)); + if (!session) return; + await this.deleteKey(this.transferSessionKey(sessionId)); + await this.deleteKey(`${this.pendingSessionsPrefix(session.targetDeviceId)}${sessionId}.json`); + } +} diff --git a/apps/api/src/interfaces/http/app.ts b/apps/api/src/interfaces/http/app.ts index d82512f8..37c9166c 100644 --- a/apps/api/src/interfaces/http/app.ts +++ b/apps/api/src/interfaces/http/app.ts @@ -36,6 +36,13 @@ import { registerSavesRoutes } from "@interfaces/http/routes/saves.routes"; import { registerShareRoutes } from "@interfaces/http/routes/share.routes"; import { registerNotificationRoutes } from "@interfaces/http/routes/notifications.routes"; import { registerInviteRoutes } from "@interfaces/http/routes/invites.routes"; +import { registerInventoryRoutes } from "@interfaces/http/routes/inventory.routes"; +import { PublishDeviceInventoryUseCase } from "@application/use-cases/PublishDeviceInventoryUseCase"; +import { ListGameProvidersUseCase } from "@application/use-cases/ListGameProvidersUseCase"; +import { CreateTransferSessionUseCase } from "@application/use-cases/CreateTransferSessionUseCase"; +import { RecordInventoryHeartbeatUseCase } from "@application/use-cases/RecordInventoryHeartbeatUseCase"; +import { ListPendingTransferSessionsUseCase } from "@application/use-cases/ListPendingTransferSessionsUseCase"; +import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository"; import { registerProfileRoutes } from "@interfaces/http/routes/users.routes"; import { registerObservabilityRoutes } from "@interfaces/http/routes/observability.routes"; import { verifyUserAccessToken } from "@shared/accessToken"; @@ -48,6 +55,7 @@ export interface AppDependencies { gameStatRepository?: GameStatRepository; steamSeedRepository?: S3SteamSeedRepository; cloudInviteRepository?: CloudInviteRepository; + gameInventoryRepository?: GameInventoryRepository; shareTokenStore?: ShareTokenS3; notificationStore?: S3NotificationStore; connectionRepository?: ConnectionRepository; @@ -148,6 +156,20 @@ export async function buildApp(deps: AppDependencies): Promise }); } + if (deps.gameInventoryRepository && deps.cloudInviteRepository) { + await registerInventoryRoutes(app, { + publishDeviceInventoryUseCase: new PublishDeviceInventoryUseCase(deps.gameInventoryRepository), + listGameProvidersUseCase: new ListGameProvidersUseCase(deps.gameInventoryRepository, deps.cloudInviteRepository), + createTransferSessionUseCase: new CreateTransferSessionUseCase( + deps.gameInventoryRepository, + deps.cloudInviteRepository + ), + recordInventoryHeartbeatUseCase: new RecordInventoryHeartbeatUseCase(deps.gameInventoryRepository), + listPendingTransferSessionsUseCase: new ListPendingTransferSessionsUseCase(deps.gameInventoryRepository), + gameInventoryRepository: deps.gameInventoryRepository, + }); + } + app.get( "/health", { diff --git a/apps/api/src/interfaces/http/routes/inventory.routes.ts b/apps/api/src/interfaces/http/routes/inventory.routes.ts new file mode 100644 index 00000000..01227b8e --- /dev/null +++ b/apps/api/src/interfaces/http/routes/inventory.routes.ts @@ -0,0 +1,129 @@ +import type { FastifyInstance, FastifyReply } from "fastify"; +import { getUserId, getErrorMessage } from "@shared/utils"; +import type { PublishDeviceInventoryUseCase } from "@application/use-cases/PublishDeviceInventoryUseCase"; +import type { ListGameProvidersUseCase } from "@application/use-cases/ListGameProvidersUseCase"; +import type { CreateTransferSessionUseCase } from "@application/use-cases/CreateTransferSessionUseCase"; +import type { RecordInventoryHeartbeatUseCase } from "@application/use-cases/RecordInventoryHeartbeatUseCase"; +import type { ListPendingTransferSessionsUseCase } from "@application/use-cases/ListPendingTransferSessionsUseCase"; +import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository"; +import { + CreateTransferSessionSchema, + type CreateTransferSessionBody, + InventoryHeartbeatSchema, + type InventoryHeartbeatBody, + PublishDeviceInventorySchema, + type PublishDeviceInventoryBody, +} from "@interfaces/schema/inventory"; + +export async function registerInventoryRoutes( + app: FastifyInstance, + deps: { + publishDeviceInventoryUseCase: PublishDeviceInventoryUseCase; + listGameProvidersUseCase: ListGameProvidersUseCase; + createTransferSessionUseCase: CreateTransferSessionUseCase; + recordInventoryHeartbeatUseCase: RecordInventoryHeartbeatUseCase; + listPendingTransferSessionsUseCase: ListPendingTransferSessionsUseCase; + gameInventoryRepository: GameInventoryRepository; + } +): Promise { + app.put<{ Params: { deviceId: string }; Body: PublishDeviceInventoryBody }>( + "/inventory/devices/:deviceId", + { schema: { body: PublishDeviceInventorySchema } }, + async (request, reply: FastifyReply) => { + try { + const userId = getUserId(request); + const deviceId = request.params.deviceId.trim(); + const body = request.body; + await deps.publishDeviceInventoryUseCase.execute({ + userId, + deviceId, + deviceName: body.deviceName, + manifestVersion: body.manifestVersion, + contentHash: body.contentHash, + updatedAt: body.updatedAt, + sharingEnabled: body.sharingEnabled, + games: body.games, + }); + return reply.send({ ok: true }); + } catch (err) { + return reply.status(400).send({ error: "Bad Request", message: getErrorMessage(err) }); + } + } + ); + + app.post<{ Params: { deviceId: string }; Body: InventoryHeartbeatBody }>( + "/inventory/devices/:deviceId/heartbeat", + { schema: { body: InventoryHeartbeatSchema } }, + async (request, reply: FastifyReply) => { + try { + const userId = getUserId(request); + await deps.recordInventoryHeartbeatUseCase.execute(userId, request.params.deviceId, request.body.appVersion); + return reply.send({ ok: true }); + } catch (err) { + return reply.status(400).send({ error: "Bad Request", message: getErrorMessage(err) }); + } + } + ); + + app.delete<{ Params: { deviceId: string } }>("/inventory/devices/:deviceId", async (request, reply: FastifyReply) => { + try { + const userId = getUserId(request); + await deps.gameInventoryRepository.deleteDeviceInventory(userId, request.params.deviceId.trim()); + return reply.send({ ok: true }); + } catch (err) { + return reply.status(400).send({ error: "Bad Request", message: getErrorMessage(err) }); + } + }); + + app.get<{ Querystring: { gameKey?: string } }>("/inventory/providers", async (request, reply: FastifyReply) => { + try { + const userId = getUserId(request); + const gameKey = (request.query.gameKey ?? "").trim(); + if (!gameKey) { + return reply.status(400).send({ error: "Bad Request", message: "gameKey is required" }); + } + const result = await deps.listGameProvidersUseCase.execute({ requesterUserId: userId, gameKey }); + return reply.send(result); + } catch (err) { + return reply.status(400).send({ error: "Bad Request", message: getErrorMessage(err) }); + } + }); + + app.get<{ Querystring: { deviceId?: string } }>( + "/inventory/transfer-sessions/pending", + async (request, reply: FastifyReply) => { + try { + getUserId(request); + const deviceId = (request.query.deviceId ?? "").trim(); + if (!deviceId) { + return reply.status(400).send({ error: "Bad Request", message: "deviceId is required" }); + } + const items = await deps.listPendingTransferSessionsUseCase.execute(deviceId); + return reply.send({ items }); + } catch (err) { + return reply.status(400).send({ error: "Bad Request", message: getErrorMessage(err) }); + } + } + ); + + app.post<{ Body: CreateTransferSessionBody }>( + "/inventory/transfer-sessions", + { schema: { body: CreateTransferSessionSchema } }, + async (request, reply: FastifyReply) => { + try { + const userId = getUserId(request); + const body = request.body; + const session = await deps.createTransferSessionUseCase.execute({ + requesterUserId: userId, + targetUserId: body.targetUserId, + targetDeviceId: body.targetDeviceId, + gameKey: body.gameKey, + manifestHash: body.manifestHash, + }); + return reply.send(session); + } catch (err) { + return reply.status(400).send({ error: "Bad Request", message: getErrorMessage(err) }); + } + } + ); +} diff --git a/apps/api/src/interfaces/http/server.ts b/apps/api/src/interfaces/http/server.ts index 5aefcd75..2dfa04dd 100644 --- a/apps/api/src/interfaces/http/server.ts +++ b/apps/api/src/interfaces/http/server.ts @@ -3,6 +3,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { buildApp } from "@interfaces/http/app"; import { S3NotificationStore } from "@infrastructure/persistence/S3NotificationStore"; import { S3CloudInviteRepository } from "@infrastructure/persistence/S3CloudInviteRepository"; +import { S3GameInventoryRepository } from "@infrastructure/persistence/S3GameInventoryRepository"; import { S3SaveRepository } from "@infrastructure/persistence/S3SaveRepository"; import { S3SteamSeedRepository } from "@infrastructure/persistence/S3SteamSeedRepository"; import { ShareTokenS3 } from "@infrastructure/share/ShareTokenS3"; @@ -36,6 +37,7 @@ const steamSeedRepository = new S3SteamSeedRepository(s3, bucketName); const shareTokenStore = new ShareTokenS3(s3, bucketName); const notificationStore = new S3NotificationStore(s3, bucketName); const cloudInviteRepository = new S3CloudInviteRepository(s3, bucketName); +const gameInventoryRepository = new S3GameInventoryRepository(s3, bucketName, cloudInviteRepository); const gameStatRepository = gameStatsTable ? new DynamoDbGameStatRepository(dynamoClient, gameStatsTable) : undefined; const saveFileIndexRepository = saveFilesIndexTable ? new DynamoDbSaveFileIndexRepository(dynamoClient, saveFilesIndexTable) @@ -52,6 +54,7 @@ async function main() { shareTokenStore, notificationStore, cloudInviteRepository, + gameInventoryRepository, gameStatRepository, connectionRepository, }); diff --git a/apps/api/src/interfaces/lambda/handler.ts b/apps/api/src/interfaces/lambda/handler.ts index 7630ec34..f8cf80c8 100644 --- a/apps/api/src/interfaces/lambda/handler.ts +++ b/apps/api/src/interfaces/lambda/handler.ts @@ -7,6 +7,7 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-l import { buildApp } from "@interfaces/http/app"; import { S3NotificationStore } from "@infrastructure/persistence/S3NotificationStore"; import { S3CloudInviteRepository } from "@infrastructure/persistence/S3CloudInviteRepository"; +import { S3GameInventoryRepository } from "@infrastructure/persistence/S3GameInventoryRepository"; import { S3SaveRepository } from "@infrastructure/persistence/S3SaveRepository"; import { S3SteamSeedRepository } from "@infrastructure/persistence/S3SteamSeedRepository"; import { ShareTokenS3 } from "@infrastructure/share/ShareTokenS3"; @@ -54,6 +55,7 @@ const steamSeedRepository = new S3SteamSeedRepository(s3, bucketName); const shareTokenStore = new ShareTokenS3(s3, bucketName); const notificationStore = new S3NotificationStore(s3, bucketName); const cloudInviteRepository = new S3CloudInviteRepository(s3, bucketName); +const gameInventoryRepository = new S3GameInventoryRepository(s3, bucketName, cloudInviteRepository); const gameStatRepository = new DynamoDbGameStatRepository(dynamoClient, gameStatsTable); const saveFileIndexRepository = saveFilesIndexTable ? new DynamoDbSaveFileIndexRepository(dynamoClient, saveFilesIndexTable) @@ -78,6 +80,7 @@ function initProxy(): Promise { shareTokenStore, notificationStore, cloudInviteRepository, + gameInventoryRepository, gameStatRepository, connectionRepository, }); diff --git a/apps/api/src/interfaces/schema/inventory.ts b/apps/api/src/interfaces/schema/inventory.ts new file mode 100644 index 00000000..844c1813 --- /dev/null +++ b/apps/api/src/interfaces/schema/inventory.ts @@ -0,0 +1,54 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const InventoryFileSchema = Type.Object({ + relativePath: Type.String({ minLength: 1 }), + size: Type.Integer({ minimum: 0 }), + hash: Type.String({ minLength: 8 }), +}); + +const GameInventoryEntrySchema = Type.Object({ + gameKey: Type.String({ minLength: 3 }), + displayName: Type.String({ minLength: 1 }), + status: Type.Literal("verified"), + payloadKind: Type.Union([Type.Literal("installedFolder"), Type.Literal("sourcesArchive")]), + totalBytes: Type.Integer({ minimum: 0 }), + fileCount: Type.Integer({ minimum: 0 }), + manifestHash: Type.String({ minLength: 8 }), + verifiedAt: Type.String({ minLength: 10 }), + files: Type.Array(InventoryFileSchema), + sourcesArchive: Type.Optional( + Type.Object({ + jobId: Type.String(), + relativePath: Type.String(), + size: Type.Integer({ minimum: 0 }), + hash: Type.String(), + verifiedAt: Type.String(), + }) + ), +}); + +export const PublishDeviceInventorySchema = Type.Object({ + deviceName: Type.String({ minLength: 1 }), + manifestVersion: Type.Integer({ minimum: 1 }), + contentHash: Type.String({ minLength: 8 }), + updatedAt: Type.String({ minLength: 10 }), + sharingEnabled: Type.Boolean(), + games: Type.Array(GameInventoryEntrySchema), +}); + +export type PublishDeviceInventoryBody = Static; + +export const InventoryHeartbeatSchema = Type.Object({ + appVersion: Type.Optional(Type.String()), +}); + +export type InventoryHeartbeatBody = Static; + +export const CreateTransferSessionSchema = Type.Object({ + targetUserId: Type.String({ minLength: 1 }), + targetDeviceId: Type.String({ minLength: 1 }), + gameKey: Type.String({ minLength: 3 }), + manifestHash: Type.String({ minLength: 8 }), +}); + +export type CreateTransferSessionBody = Static; diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 87916dfd..9fa55566 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -154,6 +154,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_cfg" version = "0.1.0" @@ -442,6 +454,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backoff" version = "0.4.0" @@ -554,6 +618,20 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1107,6 +1185,12 @@ dependencies = [ "typewit", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.1" @@ -1937,6 +2021,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2261,6 +2356,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix 0.38.44", + "windows-targets 0.52.6", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2779,6 +2884,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -2792,6 +2903,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3003,6 +3115,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "image" version = "0.25.10" @@ -3839,6 +3961,26 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "mdns-sd" +version = "0.13.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328f4e1041f7cfeb3affccb814ddbe2f004856a2ce769c8bf22080d74c5204c6" +dependencies = [ + "fastrand 2.4.1", + "flume", + "if-addrs", + "log", + "mio 1.2.0", + "socket2 0.5.10", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3938,6 +4080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -5873,7 +6016,9 @@ dependencies = [ name = "savecloud-desktop" version = "1.24.2" dependencies = [ + "axum", "base64 0.22.1", + "blake3", "bytes", "chrono", "cpal", @@ -5883,6 +6028,7 @@ dependencies = [ "file_icon_provider", "filetime", "futures-util", + "gethostname 0.5.0", "gilrs", "half", "hex 0.4.3", @@ -5890,6 +6036,7 @@ dependencies = [ "libc", "librqbit", "log", + "mdns-sd", "mlua", "moka", "notify", @@ -5929,6 +6076,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "tokio-util", + "tower-http", "unicode-normalization", "url", "urlencoding", @@ -6183,6 +6331,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6397,6 +6556,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -6455,6 +6624,15 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spinning_top" version = "0.3.0" @@ -6952,7 +7130,7 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" dependencies = [ - "gethostname", + "gethostname 1.1.0", "log", "os_info", "serde", @@ -7485,6 +7663,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7528,6 +7707,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5505e47d..cdb9ef95 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -56,7 +56,12 @@ rusqlite = { version = "0.39.0", features = ["bundled"] } unicode-normalization = "0.1" uuid = { version = "1", features = ["v4", "serde"] } sha2 = "0.10" +blake3 = "1" hex = "0.4" +axum = "0.8" +tower-http = { version = "0.6", features = ["cors"] } +mdns-sd = "0.13" +gethostname = "0.5" r2d2 = "0.8.10" r2d2_sqlite = "0.33.0" url = "2.5.8" diff --git a/apps/desktop/src-tauri/src/commands/inventory.rs b/apps/desktop/src-tauri/src/commands/inventory.rs new file mode 100644 index 00000000..2b55fdf4 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/inventory.rs @@ -0,0 +1,90 @@ +//! Comandos Tauri para inventario de juegos y transferencia LAN. + +use serde::Serialize; +use tauri::command; + +use crate::peer_inventory::{ + game_key_for_catalog_steam, list_providers_from_api, load_local_manifest, + publish_local_inventory, GameProvidersResponseDto, +}; +use crate::peer_lan::{poll_and_serve_pending_sessions, probe_lan_devices, LanDeviceProbe}; +use crate::sources::commands::downloads::start_peer_game_download_inner; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalInventoryDto { + pub manifest: Option, +} + +#[command] +pub async fn inventory_scan_and_publish( + force_scan: bool, +) -> Result { + publish_local_inventory(force_scan).await +} + +#[command] +pub fn inventory_get_local() -> Result { + Ok(LocalInventoryDto { + manifest: load_local_manifest()?, + }) +} + +#[command] +pub async fn inventory_list_providers( + game_key: String, +) -> Result { + list_providers_from_api(&game_key).await +} + +#[command] +pub async fn inventory_probe_lan(device_ids: Vec) -> Result, String> { + probe_lan_devices(device_ids).await +} + +#[command] +pub async fn inventory_poll_pending_sessions() -> Result { + poll_and_serve_pending_sessions().await +} + +#[command] +pub fn inventory_game_key_from_steam_app_id( + steam_app_id: String, +) -> Result, String> { + Ok(game_key_for_catalog_steam(&steam_app_id)) +} + +#[command] +pub async fn start_peer_game_download( + app: tauri::AppHandle, + game_key: String, + title: String, + destination_dir: String, + target_user_id: String, + target_device_id: String, + manifest_hash: String, +) -> Result { + start_peer_game_download_inner( + app, + game_key, + title, + destination_dir, + target_user_id, + target_device_id, + manifest_hash, + ) + .await +} + +#[command] +pub async fn set_share_game_inventory_with_cloud(enabled: bool) -> Result<(), String> { + let mut settings = crate::config::load_settings(); + settings.share_game_inventory_with_cloud = enabled; + crate::config::save_settings(&settings)?; + if enabled { + let _ = publish_local_inventory(true).await; + } else if let Some(m) = load_local_manifest()? { + let _ = crate::peer_inventory::delete_cloud_inventory(&m.device_id).await; + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 7fdf2c58..c0325fc4 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ //! Comandos Tauri expuestos al frontend. +pub mod inventory; pub mod logs; pub mod scan; pub mod share; diff --git a/apps/desktop/src-tauri/src/commands/scan/filters.rs b/apps/desktop/src-tauri/src/commands/scan/filters.rs index 0107bdfc..c08d5efe 100644 --- a/apps/desktop/src-tauri/src/commands/scan/filters.rs +++ b/apps/desktop/src-tauri/src/commands/scan/filters.rs @@ -229,3 +229,33 @@ pub(super) fn collect_files(dir_path: &Path) -> Vec { collect_files_recursive(dir_path, 0, &mut names); names } + +/// Rutas absolutas y nombre de carpeta hoja (o padre emu) que indican guardados. +pub fn path_suggests_save_location(dir_path: &Path) -> bool { + if !dir_path.exists() { + return true; + } + + let lower = dir_path.to_string_lossy().to_lowercase(); + if lower.contains("saved games") + || lower.contains("savegames") + || lower.contains("\\saves\\") + || lower.ends_with("\\saves") + || lower.contains("remote\\win64_save") + || lower.contains("remote/win64_save") + { + return true; + } + + let folder_name = dir_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if folder_name_hints_save(folder_name) || folder_name_hints_steam_emu(folder_name) { + return true; + } + + let parent_name = dir_path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(""); + folder_name_hints_steam_emu(parent_name) +} diff --git a/apps/desktop/src-tauri/src/commands/scan/mod.rs b/apps/desktop/src-tauri/src/commands/scan/mod.rs index c01650c8..6d63ab07 100644 --- a/apps/desktop/src-tauri/src/commands/scan/mod.rs +++ b/apps/desktop/src-tauri/src/commands/scan/mod.rs @@ -13,6 +13,8 @@ mod extensions; mod filters; mod paths; +pub use filters::path_suggests_save_location; + use crate::config; #[cfg(target_os = "windows")] use crate::{manifest, steam}; diff --git a/apps/desktop/src-tauri/src/commands/share/invites.rs b/apps/desktop/src-tauri/src/commands/share/invites.rs index 2809dd85..b6187639 100644 --- a/apps/desktop/src-tauri/src/commands/share/invites.rs +++ b/apps/desktop/src-tauri/src/commands/share/invites.rs @@ -9,10 +9,8 @@ fn normalize_base_url(input: &str) -> String { return s; } if s.starts_with("http://") { - // API Gateway suele requerir HTTPS; upgrade automático para evitar fallos por puerto 80. return format!("https://{}", s.trim_start_matches("http://")); } - // Si el usuario pegó solo el host (sin esquema), asumimos HTTPS. format!("https://{}", s) } diff --git a/apps/desktop/src-tauri/src/config/config_cmds.rs b/apps/desktop/src-tauri/src/config/config_cmds.rs index 51f05798..ff27c799 100644 --- a/apps/desktop/src-tauri/src/config/config_cmds.rs +++ b/apps/desktop/src-tauri/src/config/config_cmds.rs @@ -98,6 +98,7 @@ pub fn get_config() -> ConfigDto { .map(|_| config::MASKED_STEAM_WEB_API_KEY.to_string()), share_visual_profile_with_hosts: settings.share_visual_profile_with_hosts, share_visual_profile_with_members: settings.share_visual_profile_with_members, + share_game_inventory_with_cloud: settings.share_game_inventory_with_cloud, game_mode_enabled: settings.game_mode_enabled, game_mode_apply_power_profile: settings.game_mode_apply_power_profile, game_mode_reduce_capture_overhead: settings.game_mode_reduce_capture_overhead, diff --git a/apps/desktop/src-tauri/src/config/io.rs b/apps/desktop/src-tauri/src/config/io.rs index 6643720a..d795d28c 100644 --- a/apps/desktop/src-tauri/src/config/io.rs +++ b/apps/desktop/src-tauri/src/config/io.rs @@ -306,6 +306,7 @@ pub fn get_combined_config() -> Config { profile_frame: settings.profile_frame.clone(), share_visual_profile_with_hosts: settings.share_visual_profile_with_hosts, share_visual_profile_with_members: settings.share_visual_profile_with_members, + share_game_inventory_with_cloud: settings.share_game_inventory_with_cloud, games: library.games, operation_history: history.entries, gamification: load_gamification(), @@ -366,6 +367,7 @@ pub fn apply_combined_config(cfg: &Config) -> Result<(), String> { current_settings.profile_frame = cfg.profile_frame.clone().or(current_settings.profile_frame); current_settings.share_visual_profile_with_hosts = cfg.share_visual_profile_with_hosts; current_settings.share_visual_profile_with_members = cfg.share_visual_profile_with_members; + current_settings.share_game_inventory_with_cloud = cfg.share_game_inventory_with_cloud; current_settings.game_mode_enabled = cfg.game_mode_enabled; current_settings.game_mode_apply_power_profile = cfg.game_mode_apply_power_profile; diff --git a/apps/desktop/src-tauri/src/config/models.rs b/apps/desktop/src-tauri/src/config/models.rs index 6ecf7baf..c00f3de7 100644 --- a/apps/desktop/src-tauri/src/config/models.rs +++ b/apps/desktop/src-tauri/src/config/models.rs @@ -72,6 +72,9 @@ pub struct AppSettings { /// Si es true, los miembros activos de la nube de este usuario (anfitrión) pueden ver avatar/fondo/marco al cargar su perfil. #[serde(default)] pub share_visual_profile_with_members: bool, + /// Si es true, publica el inventario verificado de juegos instalados a los miembros del cloud (opt-out). + #[serde(default = "default_true")] + pub share_game_inventory_with_cloud: bool, /// Clave [Steam Web API](https://steamcommunity.com/dev/apikey) para `IStoreService/GetAppList` (catálogo local). /// /// No se serializa en JSON; se guarda en el almacén seguro del SO (Keyring), igual que `api_key`. @@ -218,6 +221,8 @@ pub struct Config { pub share_visual_profile_with_hosts: bool, #[serde(default)] pub share_visual_profile_with_members: bool, + #[serde(default = "default_true")] + pub share_game_inventory_with_cloud: bool, pub games: Vec, #[serde(default)] pub operation_history: Vec, @@ -279,6 +284,8 @@ pub struct ConfigDto { pub share_visual_profile_with_hosts: bool, #[serde(default)] pub share_visual_profile_with_members: bool, + #[serde(default = "default_true")] + pub share_game_inventory_with_cloud: bool, #[serde(default)] pub game_mode_enabled: bool, #[serde(default = "default_true")] diff --git a/apps/desktop/src-tauri/src/config/profile_storage.rs b/apps/desktop/src-tauri/src/config/profile_storage.rs index fb6a615a..73a6f8df 100644 --- a/apps/desktop/src-tauri/src/config/profile_storage.rs +++ b/apps/desktop/src-tauri/src/config/profile_storage.rs @@ -384,6 +384,7 @@ pub fn initialize_profile_storage(profile: &super::profiles::Profile) -> Result< profile_frame: profile.profile_frame.clone(), share_visual_profile_with_hosts: profile.share_visual_profile_with_hosts, share_visual_profile_with_members: profile.share_visual_profile_with_members, + share_game_inventory_with_cloud: true, steam_web_api_key: None, game_mode_enabled: false, game_mode_apply_power_profile: true, diff --git a/apps/desktop/src-tauri/src/ipc/handlers.rs b/apps/desktop/src-tauri/src/ipc/handlers.rs index c6f263b3..85e2322d 100644 --- a/apps/desktop/src-tauri/src/ipc/handlers.rs +++ b/apps/desktop/src-tauri/src/ipc/handlers.rs @@ -129,6 +129,14 @@ pub fn register_all_commands(builder: Builder) -> Builder { crate::commands::share::invites::remove_cloud_member, crate::commands::share::invites::list_cloud_memberships, crate::commands::share::invites::list_cloud_presence, + crate::commands::inventory::inventory_scan_and_publish, + crate::commands::inventory::inventory_get_local, + crate::commands::inventory::inventory_list_providers, + crate::commands::inventory::inventory_probe_lan, + crate::commands::inventory::inventory_poll_pending_sessions, + crate::commands::inventory::inventory_game_key_from_steam_app_id, + crate::commands::inventory::start_peer_game_download, + crate::commands::inventory::set_share_game_inventory_with_cloud, crate::steam_catalog::commands::sync::sync_steam_catalog, crate::steam_catalog::commands::sync::reset_steam_catalog_sync, crate::steam_catalog::commands::trending::sync_steam_store_trending, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 31ccbdca..c02cac8d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ mod ipc; #[cfg(target_os = "windows")] mod manifest; mod network; +mod peer_inventory; +mod peer_lan; mod notifications; mod observability; mod overlay; diff --git a/apps/desktop/src-tauri/src/network/mod.rs b/apps/desktop/src-tauri/src/network/mod.rs index 9b29e942..4b5955c7 100644 --- a/apps/desktop/src-tauri/src/network/mod.rs +++ b/apps/desktop/src-tauri/src/network/mod.rs @@ -38,6 +38,7 @@ use std::sync::LazyLock; use std::time::Duration; pub mod hoster_download; +pub mod stream_download; pub use hoster_download::{ ensure_download_success, ensure_resolve_success, get, get_with_profile, head_no_redirect, diff --git a/apps/desktop/src-tauri/src/network/stream_download.rs b/apps/desktop/src-tauri/src/network/stream_download.rs new file mode 100644 index 00000000..34220b8a --- /dev/null +++ b/apps/desktop/src-tauri/src/network/stream_download.rs @@ -0,0 +1,119 @@ +//! Descarga HTTP por streaming reutilizable (hosters, LAN peer, etc.). + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures_util::StreamExt; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use tokio::io::AsyncWriteExt; + +use crate::network::ensure_download_success; +use crate::utils::transfer_metrics::TransferSpeedTracker; + +/// Bytes entre emisiones de progreso. +pub const STREAM_PROGRESS_EMIT_BYTES: u64 = 512 * 1024; +/// Intervalo mínimo entre emisiones. +pub const STREAM_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(500); + +pub struct StreamDownloadResult { + pub loaded: u64, +} + +/// Descarga una URL a un archivo destino con progreso y cancelación cooperativa. +pub async fn stream_url_to_file( + client: &reqwest::Client, + uri: &str, + output_path: &Path, + total_hint: u64, + auth_bearer: Option<&str>, + cancel_flag: Arc, + mut on_progress: impl FnMut(u64, u64, u64, Option) -> Result<(), String>, +) -> Result { + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("No se pudo crear directorio: {e}"))?; + } + + let mut headers = HeaderMap::new(); + if let Some(token) = auth_bearer { + let value = format!("Bearer {token}"); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&value).map_err(|e| e.to_string())?, + ); + } + + let mut req = client.get(uri); + for (k, v) in headers.iter() { + req = req.header(k, v); + } + let response = req.send().await.map_err(|e| format!("Error HTTP: {e}"))?; + let response = ensure_download_success(response).map_err(|e| e.user_message())?; + + let total = if total_hint > 0 { + total_hint + } else { + response.content_length().unwrap_or(0) + }; + + let mut file = tokio::fs::File::create(output_path) + .await + .map_err(|e| format!("No se pudo crear archivo: {e}"))?; + + let mut speed_tracker = TransferSpeedTracker::new(); + let mut emit_progress = |loaded: u64, final_emit: bool| -> Result<(), String> { + let now = Instant::now(); + let sample = if final_emit { + speed_tracker.record_final(loaded, total, now) + } else { + speed_tracker.record(loaded, total, now) + }; + on_progress( + loaded, + total, + sample.download_speed_bytes, + sample.eta_seconds, + ) + }; + + let mut loaded = 0_u64; + emit_progress(loaded, false)?; + + let mut last_emit_loaded = 0_u64; + let mut last_emit_at = Instant::now(); + let mut stream = response.bytes_stream(); + + while let Some(next) = stream.next().await { + if cancel_flag.load(Ordering::Relaxed) { + let _ = tokio::fs::remove_file(output_path).await; + return Err("stopped_by_user".to_string()); + } + let chunk = next.map_err(|e| format!("Error leyendo stream: {e}"))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("Error escribiendo: {e}"))?; + loaded = loaded.saturating_add(chunk.len() as u64); + + let bytes_step = loaded.saturating_sub(last_emit_loaded) >= STREAM_PROGRESS_EMIT_BYTES; + let time_step = last_emit_at.elapsed() >= STREAM_PROGRESS_EMIT_INTERVAL; + let reached_end = total > 0 && loaded >= total; + if (bytes_step && time_step) || reached_end { + emit_progress(loaded, false)?; + last_emit_loaded = loaded; + last_emit_at = Instant::now(); + } + } + + if loaded != last_emit_loaded { + emit_progress(loaded, true)?; + } + + file.flush() + .await + .map_err(|e| format!("Flush falló: {e}"))?; + + Ok(StreamDownloadResult { loaded }) +} diff --git a/apps/desktop/src-tauri/src/notifications/writer.rs b/apps/desktop/src-tauri/src/notifications/writer.rs index 254d73fc..4f8f66b5 100644 --- a/apps/desktop/src-tauri/src/notifications/writer.rs +++ b/apps/desktop/src-tauri/src/notifications/writer.rs @@ -70,6 +70,7 @@ fn protocol_label(p: &DownloadProtocol) -> &'static str { DownloadProtocol::Http => "HTTP", DownloadProtocol::TorrentMagnet => "Magnet", DownloadProtocol::TorrentFile => "Torrent", + DownloadProtocol::PeerLan => "Transferencia LAN", DownloadProtocol::Unknown => "Desconocido", } } @@ -114,9 +115,10 @@ pub fn try_record_source_download_terminal(app: &AppHandle, job: &SourceDownload "error", format!("Error al descargar: {}", job.title), job.error - .as_deref() - .filter(|s| !s.trim().is_empty()) - .unwrap_or("La descarga falló.").to_string(), + .as_deref() + .filter(|s| !s.trim().is_empty()) + .unwrap_or("La descarga falló.") + .to_string(), ), SourceJobStatus::Cancelled => ( "warning", diff --git a/apps/desktop/src-tauri/src/peer_inventory/game_key.rs b/apps/desktop/src-tauri/src/peer_inventory/game_key.rs new file mode 100644 index 00000000..179a63ab --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/game_key.rs @@ -0,0 +1,26 @@ +//! Claves canónicas de juego para el índice de inventario. + +use crate::config::ConfiguredGame; + +/// `steam:{appId}` para catálogo / instalación Steam. +pub fn game_key_for_catalog_steam(steam_app_id: &str) -> Option { + let id = steam_app_id.trim(); + if id.is_empty() { + return None; + } + Some(format!("steam:{id}")) +} + +/// `steam:{appId}` o `savecloud:{configuredGameId}`. +pub fn game_key_for_configured_game(game: &ConfiguredGame) -> Option { + if let Some(ref steam) = game.steam_app_id { + if let Some(key) = game_key_for_catalog_steam(steam) { + return Some(key); + } + } + let id = game.id.trim(); + if id.is_empty() { + return None; + } + Some(format!("savecloud:{id}")) +} diff --git a/apps/desktop/src-tauri/src/peer_inventory/install_paths.rs b/apps/desktop/src-tauri/src/peer_inventory/install_paths.rs new file mode 100644 index 00000000..34ee6e89 --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/install_paths.rs @@ -0,0 +1,287 @@ +//! Resolución de carpetas de instalación (no confundir con rutas de guardado en `paths`). + +use std::fs; +use std::path::{Path, PathBuf}; + +use walkdir::WalkDir; + +use crate::commands::scan::path_suggests_save_location; +use crate::config::ConfiguredGame; +use crate::sources::domain::{DownloadProtocol, SourceDownloadJob, SourceJobStatus}; +use crate::sources::store as sources_store; + +const MIN_INSTALL_BYTES: u64 = 100 * 1024 * 1024; + +/// Carpeta de instalación verificable para un juego de biblioteca. +pub fn resolve_install_root(game: &ConfiguredGame, game_key: &str) -> Option { + if let Some(root) = install_root_from_launch_executable(game) { + if root.is_dir() && looks_like_game_install_dir(&root, InstallRootTrust::LaunchExecutable) { + return Some(root); + } + } + + let mut candidates: Vec = Vec::new(); + if let Some(root) = install_root_from_steam(game, game_key) { + candidates.push(root); + } + candidates.extend(install_roots_from_jobs(game, game_key)); + + candidates + .into_iter() + .filter(|p| p.is_dir() && looks_like_game_install_dir(p, InstallRootTrust::Discovered)) + .max_by_key(|p| dir_total_bytes(p).unwrap_or(0)) +} + +enum InstallRootTrust { + /// El usuario configuró el `.exe` de lanzamiento. + LaunchExecutable, + /// Steam, torrent u otra heurística. + Discovered, +} + +fn install_root_from_launch_executable(game: &ConfiguredGame) -> Option { + let exe = game.launch_executable_path.as_ref()?.trim(); + if exe.is_empty() { + return None; + } + let path = PathBuf::from(exe); + if !path.is_file() { + return None; + } + path.parent().map(Path::to_path_buf) +} + +fn install_root_from_steam(game: &ConfiguredGame, game_key: &str) -> Option { + let steam = game + .steam_app_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty())?; + if game_key != format!("steam:{steam}") { + return None; + } + crate::steam::resolve_steam_install_dir(steam) +} + +fn is_steam_common_install(root: &Path) -> bool { + let lower = root.to_string_lossy().to_lowercase(); + lower.contains("steamapps\\common") || lower.contains("steamapps/common") +} + +fn install_roots_from_jobs(game: &ConfiguredGame, game_key: &str) -> Vec { + let Ok(jobs) = sources_store::load_jobs() else { + return Vec::new(); + }; + + let mut roots = Vec::new(); + for job in jobs { + if job.status != SourceJobStatus::Completed { + continue; + } + if !job_matches_game_key(&job, game, game_key) { + continue; + } + let dest = PathBuf::from(&job.destination_dir); + if !dest.is_dir() { + continue; + } + match job.protocol { + DownloadProtocol::TorrentMagnet + | DownloadProtocol::TorrentFile + | DownloadProtocol::PeerLan => { + roots.push(dest); + } + DownloadProtocol::Http => { + if http_destination_has_extracted_install(&dest, job.output_file_name.as_deref()) { + roots.push(dest); + } + } + DownloadProtocol::Unknown => {} + } + } + roots +} + +fn http_destination_has_extracted_install(dest: &Path, output_file_name: Option<&str>) -> bool { + if let Some(name) = output_file_name { + let archive = dest.join(name); + if archive.is_file() { + let mut other_files = 0_u32; + let mut other_dirs = 0_u32; + if let Ok(read) = fs::read_dir(dest) { + for entry in read.flatten() { + if entry.path() == archive { + continue; + } + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + other_dirs += 1; + } else { + other_files += 1; + } + } + } + if other_dirs == 0 && other_files == 0 { + return false; + } + } + } + true +} + +pub fn job_matches_game_key( + job: &SourceDownloadJob, + game: &ConfiguredGame, + game_key: &str, +) -> bool { + if job.item_id == game_key || job.item_id == game.id { + return true; + } + if job.source_id == "peer-lan" && job.item_id == game_key { + return true; + } + + let game_slug = crate::sources::parser::slugify(&game.id); + if game_key == format!("savecloud:{game_slug}") { + let title_slug = crate::sources::parser::slugify(&job.title); + if title_slug == game_slug { + return true; + } + } + + if let Some(steam) = game + .steam_app_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + if game_key == format!("steam:{steam}") + && (job.title.contains(steam) || job.item_id.contains(steam)) + { + return true; + } + } + + false +} + +/// Rechaza carpetas que son claramente solo guardados (no instalación). +/// No usa `folder_contains_save_like_files`: dentro de un juego hay muchos `.dat`/`.ini`. +fn install_root_is_save_only(root: &Path, trust: InstallRootTrust) -> bool { + if is_steam_common_install(root) { + return false; + } + + if path_suggests_save_location(root) { + return true; + } + + if matches!(trust, InstallRootTrust::LaunchExecutable) { + return false; + } + + shallow_tree_looks_like_save_data(root) +} + +fn shallow_tree_looks_like_save_data(root: &Path) -> bool { + let mut files = 0_u32; + let mut save_like = 0_u32; + let mut ini_only = 0_u32; + + for entry in WalkDir::new(root) + .max_depth(5) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + files += 1; + let name = entry.file_name().to_string_lossy().to_lowercase(); + let ext = entry + .path() + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + if is_save_like_extension(&ext, &name) { + save_like += 1; + } + if ext == "ini" && entry.metadata().map(|m| m.len() <= 4).unwrap_or(false) { + ini_only += 1; + } + if files >= 200 { + break; + } + } + + if files == 0 { + return true; + } + if save_like * 100 / files >= 40 { + return true; + } + ini_only * 100 / files >= 70 && dir_total_bytes(root).unwrap_or(0) < MIN_INSTALL_BYTES +} + +fn is_save_like_extension(ext: &str, file_name: &str) -> bool { + matches!( + ext, + "sav" | "sl2" | "qdsav" | "hg" | "bak" | "dat" | "bin" | "json" | "log" | "vdf" | "xml" + ) || file_name.contains("autosave") + || file_name.contains("quicksave") + || file_name.starts_with("save") + || file_name.ends_with(".sav") + || file_name.ends_with(".sl2") +} + +fn looks_like_game_install_dir(root: &Path, trust: InstallRootTrust) -> bool { + if install_root_is_save_only(root, trust) { + return false; + } + + let mut exe_count = 0_u32; + let mut total_bytes = 0_u64; + let mut file_count = 0_u32; + + for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + file_count += 1; + total_bytes = total_bytes.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0)); + if entry + .path() + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("exe")) + { + exe_count += 1; + } + if file_count >= 5000 { + break; + } + } + + if exe_count > 0 && total_bytes >= MIN_INSTALL_BYTES { + return true; + } + if exe_count > 0 && file_count >= 50 { + return true; + } + if total_bytes >= 5 * 1024 * 1024 * 1024 { + return true; + } + + false +} + +fn dir_total_bytes(root: &Path) -> Result { + let mut total = 0_u64; + for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0)); + } + } + Ok(total) +} diff --git a/apps/desktop/src-tauri/src/peer_inventory/mod.rs b/apps/desktop/src-tauri/src/peer_inventory/mod.rs new file mode 100644 index 00000000..cb62bd7c --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/mod.rs @@ -0,0 +1,17 @@ +//! Inventario verificado de juegos instalados por dispositivo (manifiesto local + cloud). + +mod game_key; +mod install_paths; +mod models; +pub mod publish; +mod scanner; +pub mod store; + +pub use game_key::{game_key_for_catalog_steam, game_key_for_configured_game}; +pub use install_paths::resolve_install_root; +pub use models::*; +pub use publish::{ + delete_cloud_inventory, list_providers_from_api, publish_local_inventory, + GameProvidersResponseDto, InventoryFileDto, +}; +pub use store::{load_local_manifest, resolve_device_id}; diff --git a/apps/desktop/src-tauri/src/peer_inventory/models.rs b/apps/desktop/src-tauri/src/peer_inventory/models.rs new file mode 100644 index 00000000..f945d859 --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/models.rs @@ -0,0 +1,79 @@ +//! Modelos del manifiesto de inventario por dispositivo. + +use serde::{Deserialize, Serialize}; + +/// Entrada de un archivo en el manifiesto verificado. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InventoryFileEntry { + pub relative_path: String, + pub size: u64, + pub hash: String, +} + +/// Archivo de fuentes (torrent/HTTP) indexado como fallback. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SourcesArchiveEntry { + pub job_id: String, + pub relative_path: String, + pub size: u64, + pub hash: String, + pub verified_at: String, +} + +/// Juego verificado en este dispositivo. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GameInventoryEntry { + pub game_key: String, + pub display_name: String, + pub status: String, + pub payload_kind: String, + pub total_bytes: u64, + pub file_count: u32, + pub manifest_hash: String, + pub verified_at: String, + pub files: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources_archive: Option, +} + +/// Manifiesto completo del dispositivo (solo entradas `verified` en cloud). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInventoryManifest { + pub device_id: String, + pub device_name: String, + pub user_id: String, + pub manifest_version: u32, + pub content_hash: String, + pub updated_at: String, + pub sharing_enabled: bool, + pub games: Vec, +} + +/// Payload publicado al API (sin rutas absolutas). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishDeviceInventoryBody { + pub device_name: String, + pub manifest_version: u32, + pub content_hash: String, + pub updated_at: String, + pub sharing_enabled: bool, + pub games: Vec, +} + +impl DeviceInventoryManifest { + pub fn to_publish_body(&self) -> PublishDeviceInventoryBody { + PublishDeviceInventoryBody { + device_name: self.device_name.clone(), + manifest_version: self.manifest_version, + content_hash: self.content_hash.clone(), + updated_at: self.updated_at.clone(), + sharing_enabled: self.sharing_enabled, + games: self.games.clone(), + } + } +} diff --git a/apps/desktop/src-tauri/src/peer_inventory/publish.rs b/apps/desktop/src-tauri/src/peer_inventory/publish.rs new file mode 100644 index 00000000..caec577a --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/publish.rs @@ -0,0 +1,173 @@ +//! Publicación del manifiesto al API cloud. + +use crate::commands::sync::context::resolve_api_context; +use crate::config::load_settings; +use crate::network::API_CLIENT; + +use super::models::DeviceInventoryManifest; +use super::scanner::scan_full_inventory; +use super::store::load_local_manifest; + +/// Escanea, guarda local y publica al cloud si sharing está habilitado. +pub async fn publish_local_inventory(force_scan: bool) -> Result { + let settings = load_settings(); + let sharing = settings.share_game_inventory_with_cloud; + let user_id = settings + .user_id + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| "userId no configurado".to_string())?; + + let manifest = if force_scan { + scan_full_inventory(&user_id, sharing)? + } else if let Some(local) = load_local_manifest()? { + local + } else { + scan_full_inventory(&user_id, sharing)? + }; + + if sharing { + put_cloud_manifest(&manifest).await?; + let _ = post_cloud_heartbeat(&manifest.device_id).await; + } else { + delete_cloud_inventory(&manifest.device_id).await.ok(); + } + + Ok(manifest) +} + +async fn put_cloud_manifest(manifest: &DeviceInventoryManifest) -> Result<(), String> { + let ctx = resolve_api_context()?; + let url = format!( + "{}/inventory/devices/{}", + ctx.base_url, + urlencoding::encode(&manifest.device_id) + ); + let body = manifest.to_publish_body(); + + let client = API_CLIENT.clone(); + let res = client + .put(&url) + .header("x-api-key", &ctx.api_key) + .header("x-user-id", &ctx.user_id) + .json(&body) + .send() + .await + .map_err(|e| format!("Error publicando inventario: {e}"))?; + + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("Inventario rechazado ({status}): {text}")); + } + Ok(()) +} + +pub async fn post_cloud_heartbeat(device_id: &str) -> Result<(), String> { + let ctx = resolve_api_context()?; + let url = format!( + "{}/inventory/devices/{}/heartbeat", + ctx.base_url, + urlencoding::encode(device_id) + ); + let body = serde_json::json!({ + "appVersion": env!("CARGO_PKG_VERSION"), + }); + + let client = API_CLIENT.clone(); + let res = client + .post(&url) + .header("x-api-key", &ctx.api_key) + .header("x-user-id", &ctx.user_id) + .json(&body) + .send() + .await + .map_err(|e| format!("Heartbeat inventario: {e}"))?; + + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("Heartbeat rechazado ({status}): {text}")); + } + Ok(()) +} + +pub async fn delete_cloud_inventory(device_id: &str) -> Result<(), String> { + let ctx = resolve_api_context()?; + let url = format!( + "{}/inventory/devices/{}", + ctx.base_url, + urlencoding::encode(device_id) + ); + let client = API_CLIENT.clone(); + let res = client + .delete(&url) + .header("x-api-key", &ctx.api_key) + .header("x-user-id", &ctx.user_id) + .send() + .await + .map_err(|e| format!("Error borrando inventario: {e}"))?; + + if !res.status().is_success() && res.status() != reqwest::StatusCode::NOT_FOUND { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("DELETE inventario ({status}): {text}")); + } + Ok(()) +} + +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InventoryFileDto { + pub relative_path: String, + pub size: u64, + pub hash: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameProviderDeviceDto { + pub user_id: String, + pub device_id: String, + pub device_name: String, + pub total_bytes: u64, + pub payload_kind: String, + pub manifest_hash: String, + pub verified_at: String, + pub last_seen_at: Option, + #[serde(default)] + pub files: Option>, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameProvidersResponseDto { + pub game_key: String, + pub providers: Vec, +} + +pub async fn list_providers_from_api(game_key: &str) -> Result { + let ctx = resolve_api_context()?; + let url = format!( + "{}/inventory/providers?gameKey={}", + ctx.base_url, + urlencoding::encode(game_key) + ); + let client = API_CLIENT.clone(); + let res = client + .get(&url) + .header("x-api-key", &ctx.api_key) + .header("x-user-id", &ctx.user_id) + .send() + .await + .map_err(|e| format!("Error listando proveedores: {e}"))?; + + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("Proveedores ({status}): {text}")); + } + + res.json::() + .await + .map_err(|e| format!("JSON proveedores: {e}")) +} diff --git a/apps/desktop/src-tauri/src/peer_inventory/scanner.rs b/apps/desktop/src-tauri/src/peer_inventory/scanner.rs new file mode 100644 index 00000000..3f533a49 --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/scanner.rs @@ -0,0 +1,255 @@ +//! Escaneo BLAKE3 full_verify de carpetas instaladas y archivos de fuentes. + +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +use blake3::Hasher; +use chrono::Utc; +use walkdir::WalkDir; + +use crate::config::{self, ConfiguredGame}; +use crate::peer_inventory::game_key::game_key_for_configured_game; +use crate::sources::store as sources_store; + +use super::install_paths::{job_matches_game_key, resolve_install_root}; +use super::models::{ + DeviceInventoryManifest, GameInventoryEntry, InventoryFileEntry, SourcesArchiveEntry, +}; +use super::store::{now_iso, resolve_device_id, resolve_device_name, save_local_manifest}; + +const MANIFEST_VERSION: u32 = 1; +const HASH_PREFIX: &str = "blake3:"; + +fn hash_file(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|e| format!("No se pudo abrir {}: {e}", path.display()))?; + let mut hasher = Hasher::new(); + let mut buf = [0u8; 1024 * 1024]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| format!("Error leyendo {}: {e}", path.display()))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{HASH_PREFIX}{}", hasher.finalize().to_hex())) +} + +fn normalize_relative(path: &Path, root: &Path) -> Result { + let rel = path + .strip_prefix(root) + .map_err(|_| format!("Ruta fuera de raíz: {}", path.display()))?; + let mut parts = Vec::new(); + for comp in rel.components() { + match comp { + Component::Normal(p) => parts.push(p.to_string_lossy().replace('\\', "/")), + Component::CurDir => {} + _ => return Err(format!("Componente de ruta no permitido: {}", path.display())), + } + } + Ok(parts.join("/")) +} + +fn scan_installed_folder( + game: &ConfiguredGame, + root: &Path, +) -> Result, String> { + let game_key = game_key_for_configured_game(game) + .ok_or_else(|| "gameKey no disponible".to_string())?; + if !root.is_dir() { + return Ok(None); + } + + let display_name = game + .edition_label + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| game.id.clone()); + + let mut files = Vec::new(); + let mut total_bytes = 0_u64; + + for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let meta = fs::metadata(path).map_err(|e| e.to_string())?; + let size = meta.len(); + let hash = hash_file(path)?; + let relative_path = normalize_relative(path, root)?; + total_bytes = total_bytes.saturating_add(size); + files.push(InventoryFileEntry { + relative_path, + size, + hash, + }); + } + + if files.is_empty() { + return Ok(None); + } + + files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + + let verified_at = Utc::now().to_rfc3339(); + let manifest_hash = entry_content_hash(&files); + + Ok(Some(GameInventoryEntry { + game_key, + display_name, + status: "verified".to_string(), + payload_kind: "installedFolder".to_string(), + total_bytes, + file_count: files.len() as u32, + manifest_hash, + verified_at, + files, + sources_archive: None, + })) +} + +fn entry_content_hash(files: &[InventoryFileEntry]) -> String { + let mut hasher = Hasher::new(); + for f in files { + hasher.update(f.relative_path.as_bytes()); + hasher.update(&f.size.to_le_bytes()); + hasher.update(f.hash.as_bytes()); + } + format!("{HASH_PREFIX}{}", hasher.finalize().to_hex()) +} + +fn scan_sources_archives_for_game(game: &ConfiguredGame, game_key: &str) -> Result, String> { + let jobs = sources_store::load_jobs().unwrap_or_default(); + let mut best: Option = None; + + for job in jobs { + if job.status != crate::sources::domain::SourceJobStatus::Completed { + continue; + } + if !job_matches_game_key(&job, game, game_key) { + continue; + } + let Some(ref output_name) = job.output_file_name else { + continue; + }; + if job.protocol != crate::sources::domain::DownloadProtocol::Http { + continue; + } + let archive_path = PathBuf::from(&job.destination_dir).join(output_name); + if !archive_path.is_file() { + continue; + } + let size = fs::metadata(&archive_path).map_err(|e| e.to_string())?.len(); + let hash = hash_file(&archive_path)?; + let verified_at = Utc::now().to_rfc3339(); + let entry = SourcesArchiveEntry { + job_id: job.job_id.clone(), + relative_path: output_name.clone(), + size, + hash, + verified_at, + }; + if best.as_ref().is_none_or(|b| entry.size > b.size) { + best = Some(entry); + } + } + + Ok(best) +} + +fn upsert_verified_entry( + games: &mut Vec, + seen_keys: &mut std::collections::HashSet, + entry: GameInventoryEntry, +) { + if let Some(existing) = games.iter_mut().find(|g| g.game_key == entry.game_key) { + if entry.total_bytes > existing.total_bytes { + *existing = entry; + } + return; + } + seen_keys.insert(entry.game_key.clone()); + games.push(entry); +} + +/// Escanea biblioteca + jobs de fuentes y persiste manifiesto local verificado. +pub fn scan_full_inventory(user_id: &str, sharing_enabled: bool) -> Result { + let device_id = resolve_device_id()?; + let device_name = resolve_device_name(); + let library = config::load_library(); + + let mut games: Vec = Vec::new(); + let mut seen_keys = std::collections::HashSet::new(); + + for game in &library.games { + let Some(game_key) = game_key_for_configured_game(game) else { + continue; + }; + if let Some(root) = resolve_install_root(game, &game_key) { + if let Some(mut entry) = scan_installed_folder(game, &root)? { + if let Some(archive) = scan_sources_archives_for_game(game, &game_key)? { + entry.sources_archive = Some(archive); + } + upsert_verified_entry(&mut games, &mut seen_keys, entry); + continue; + } + } + if seen_keys.contains(&game_key) { + continue; + } + if let Some(archive) = scan_sources_archives_for_game(game, &game_key)? { + let display_name = game + .edition_label + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| game.id.clone()); + let verified_at = Utc::now().to_rfc3339(); + upsert_verified_entry(&mut games, &mut seen_keys, GameInventoryEntry { + game_key: game_key.clone(), + display_name, + status: "verified".to_string(), + payload_kind: "sourcesArchive".to_string(), + total_bytes: archive.size, + file_count: 1, + manifest_hash: archive.hash.clone(), + verified_at, + files: vec![InventoryFileEntry { + relative_path: archive.relative_path.clone(), + size: archive.size, + hash: archive.hash.clone(), + }], + sources_archive: Some(archive), + }); + } + } + + games.sort_by(|a, b| a.game_key.cmp(&b.game_key)); + + let content_hash = manifest_content_hash(&games); + let manifest = DeviceInventoryManifest { + device_id, + device_name, + user_id: user_id.to_string(), + manifest_version: MANIFEST_VERSION, + content_hash, + updated_at: now_iso(), + sharing_enabled, + games, + }; + + save_local_manifest(&manifest)?; + Ok(manifest) +} + +fn manifest_content_hash(games: &[GameInventoryEntry]) -> String { + let mut hasher = Hasher::new(); + for g in games { + hasher.update(g.game_key.as_bytes()); + hasher.update(g.manifest_hash.as_bytes()); + hasher.update(&g.total_bytes.to_le_bytes()); + } + format!("{HASH_PREFIX}{}", hasher.finalize().to_hex()) +} diff --git a/apps/desktop/src-tauri/src/peer_inventory/store.rs b/apps/desktop/src-tauri/src/peer_inventory/store.rs new file mode 100644 index 00000000..e196efab --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_inventory/store.rs @@ -0,0 +1,63 @@ +//! Persistencia local del manifiesto de inventario. + +use std::fs; +use std::path::PathBuf; + +use chrono::Utc; +use serde_json; + +use super::models::DeviceInventoryManifest; + +const LOCAL_MANIFEST_FILE: &str = "local.json"; + +pub fn inventory_dir() -> Result { + let Some(data) = crate::config::paths::data_dir() else { + return Err("No se pudo resolver data_dir".to_string()); + }; + let dir = data.join("peer_inventory"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn local_manifest_path() -> Result { + Ok(inventory_dir()?.join(LOCAL_MANIFEST_FILE)) +} + +pub fn load_local_manifest() -> Result, String> { + let path = local_manifest_path()?; + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(&path).map_err(|e| e.to_string())?; + let manifest: DeviceInventoryManifest = + serde_json::from_slice(&bytes).map_err(|e| format!("Manifiesto local inválido: {e}"))?; + Ok(Some(manifest)) +} + +pub fn save_local_manifest(manifest: &DeviceInventoryManifest) -> Result<(), String> { + let path = local_manifest_path()?; + let payload = + serde_json::to_vec_pretty(manifest).map_err(|e| format!("Serialización: {e}"))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, &payload).map_err(|e| e.to_string())?; + fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn now_iso() -> String { + Utc::now().to_rfc3339() +} + +pub fn resolve_device_name() -> String { + let name = gethostname::gethostname().to_string_lossy().trim().to_string(); + if name.is_empty() { + "SaveCloud-PC".to_string() + } else { + name + } +} + +pub fn resolve_device_id() -> Result { + let db = crate::sqlite::AppDb::open().map_err(|e| e.to_string())?; + crate::notifications::db::get_or_create_device_id(&db).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/peer_lan/discovery.rs b/apps/desktop/src-tauri/src/peer_lan/discovery.rs new file mode 100644 index 00000000..29050b22 --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_lan/discovery.rs @@ -0,0 +1,86 @@ +//! Descubrimiento mDNS de dispositivos SaveCloud en LAN. + +use std::collections::HashMap; +use std::time::Duration; + +use serde::Serialize; + +const SERVICE_TYPE: &str = "_savecloud._tcp.local."; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LanDeviceProbe { + pub device_id: String, + pub user_id: String, + pub lan_host: String, + pub port: u16, + pub reachable: bool, +} + +/// Sondea la LAN y devuelve dispositivos cuyo `deviceId` está en `target_ids`. +pub async fn probe_lan_devices(target_ids: Vec) -> Result, String> { + let wanted: std::collections::HashSet = target_ids.into_iter().collect(); + if wanted.is_empty() { + return Ok(Vec::new()); + } + + let mut found: HashMap = HashMap::new(); + + let daemon = mdns_sd::ServiceDaemon::new().map_err(|e| format!("mDNS daemon: {e}"))?; + let receiver = daemon + .browse(SERVICE_TYPE) + .map_err(|e| format!("mDNS browse: {e}"))?; + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + + while std::time::Instant::now() < deadline { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(mdns_sd::ServiceEvent::ServiceResolved(info)) => { + let device_id = txt_get(&info, "deviceId"); + let user_id = txt_get(&info, "userId"); + if device_id.is_empty() || !wanted.contains(&device_id) { + continue; + } + let host = info.get_hostname().trim_end_matches('.').to_string(); + let port = info.get_port(); + found.insert( + device_id.clone(), + LanDeviceProbe { + device_id, + user_id, + lan_host: host, + port, + reachable: true, + }, + ); + } + Ok(_) => {} + Err(_) => break, + } + } + + let _ = daemon.shutdown(); + + let out: Vec = wanted + .into_iter() + .map(|id| { + found.get(&id).cloned().unwrap_or(LanDeviceProbe { + device_id: id, + user_id: String::new(), + lan_host: String::new(), + port: 0, + reachable: false, + }) + }) + .collect(); + + Ok(out) +} + +fn txt_get(info: &mdns_sd::ServiceInfo, key: &str) -> String { + info.get_properties() + .get(key) + .and_then(|v| v.val()) + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .unwrap_or_default() +} diff --git a/apps/desktop/src-tauri/src/peer_lan/mod.rs b/apps/desktop/src-tauri/src/peer_lan/mod.rs new file mode 100644 index 00000000..746ffb8c --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_lan/mod.rs @@ -0,0 +1,11 @@ +//! Transferencia LAN entre peers (mDNS + servidor HTTP + descarga streaming). + +pub mod discovery; +mod poller; +pub mod runner; +pub mod server; +pub mod session; + +pub use discovery::{probe_lan_devices, LanDeviceProbe}; +pub use poller::{poll_and_serve_pending_sessions, spawn_pending_session_poller}; +pub use runner::{run_peer_download, PeerDownloadParams}; diff --git a/apps/desktop/src-tauri/src/peer_lan/poller.rs b/apps/desktop/src-tauri/src/peer_lan/poller.rs new file mode 100644 index 00000000..fbbe5688 --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_lan/poller.rs @@ -0,0 +1,86 @@ +//! Poll de sesiones de transferencia pendientes (lado emisor). + +use std::time::Duration; + +use crate::commands::sync::context::resolve_api_context; +use crate::config::load_settings; +use crate::network::API_CLIENT; +use crate::peer_inventory::resolve_device_id; +use crate::peer_lan::server::start_lan_server_for_session; +use crate::peer_lan::session::{register_transfer_session, session_ttl_from_iso, PendingTransferSession}; + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PendingSessionsResponse { + items: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PendingSessionDto { + token: String, + #[serde(rename = "requesterUserId")] + _requester_user_id: String, + game_key: String, + manifest_hash: String, + expires_at: String, +} + +pub async fn poll_and_serve_pending_sessions() -> Result { + let settings = load_settings(); + if !settings.share_game_inventory_with_cloud { + return Ok(0); + } + + let device_id = resolve_device_id()?; + let ctx = resolve_api_context()?; + + let url = format!( + "{}/inventory/transfer-sessions/pending?deviceId={}", + ctx.base_url, + urlencoding::encode(&device_id) + ); + + let res = API_CLIENT + .get(&url) + .header("x-api-key", &ctx.api_key) + .header("x-user-id", &ctx.user_id) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + return Ok(0); + } + + let body: PendingSessionsResponse = res.json().await.map_err(|e| e.to_string())?; + let mut served = 0_u32; + + for item in body.items { + let ttl = session_ttl_from_iso(&item.expires_at); + register_transfer_session(PendingTransferSession { + token: item.token.clone(), + game_key: item.game_key.clone(), + manifest_hash: item.manifest_hash.clone(), + expires_at: std::time::Instant::now() + ttl, + }); + + if start_lan_server_for_session(&item.token, &item.game_key) + .await + .is_ok() + { + served += 1; + } + } + + Ok(served) +} + +pub fn spawn_pending_session_poller() { + tauri::async_runtime::spawn(async { + loop { + let _ = poll_and_serve_pending_sessions().await; + tokio::time::sleep(Duration::from_secs(5)).await; + } + }); +} diff --git a/apps/desktop/src-tauri/src/peer_lan/runner.rs b/apps/desktop/src-tauri/src/peer_lan/runner.rs new file mode 100644 index 00000000..6b1ecf13 --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_lan/runner.rs @@ -0,0 +1,138 @@ +//! Runner de descarga peer LAN (cola sources). + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crate::commands::sync::context::resolve_api_context; +use crate::network::stream_download::stream_url_to_file; +use crate::network::API_CLIENT; +use crate::peer_inventory::{list_providers_from_api, InventoryFileDto}; + +pub struct PeerDownloadParams { + pub game_key: String, + pub destination_dir: String, + pub target_user_id: String, + pub target_device_id: String, + pub manifest_hash: String, +} + +pub async fn run_peer_download( + params: PeerDownloadParams, + cancel_flag: Arc, + mut on_progress: impl FnMut(u64, u64, u64, Option) -> Result<(), String>, +) -> Result<(), String> { + let session = create_transfer_session(¶ms).await?; + + for _ in 0..20 { + if cancel_flag.load(Ordering::Relaxed) { + return Err("stopped_by_user".to_string()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + let probes = crate::peer_lan::discovery::probe_lan_devices(vec![params.target_device_id.clone()]) + .await?; + let peer = probes + .into_iter() + .find(|p| p.device_id == params.target_device_id && p.reachable) + .ok_or_else(|| { + "El dispositivo no está disponible en la red local. Enciéndelo en la misma red.".to_string() + })?; + + let providers = list_providers_from_api(¶ms.game_key).await?; + let provider = providers + .providers + .into_iter() + .find(|p| p.device_id == params.target_device_id) + .ok_or_else(|| "Proveedor no encontrado en inventario".to_string())?; + + let files: Vec = provider.files.unwrap_or_default(); + if files.is_empty() { + return Err("El proveedor no tiene archivos indexados para este juego".to_string()); + } + + let dest = PathBuf::from(¶ms.destination_dir); + tokio::fs::create_dir_all(&dest) + .await + .map_err(|e| e.to_string())?; + + let total_bytes: u64 = files.iter().map(|f| f.size).sum(); + let mut loaded_total = 0_u64; + + let client = API_CLIENT.clone(); + let base = format!("http://{}:{}", peer.lan_host, peer.port); + + for file in &files { + if cancel_flag.load(Ordering::Relaxed) { + return Err("stopped_by_user".to_string()); + } + let rel = file.relative_path.replace('\\', "/"); + let url = format!("{base}/files/{rel}"); + let out = dest.join(&rel); + if let Some(parent) = out.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + } + + let file_offset = loaded_total; + let result = stream_url_to_file( + &client, + &url, + &out, + file.size, + Some(&session.token), + cancel_flag.clone(), + |loaded, _total, speed, eta| { + on_progress( + file_offset.saturating_add(loaded), + total_bytes, + speed, + eta, + ) + }, + ) + .await?; + + loaded_total = loaded_total.saturating_add(result.loaded); + on_progress(loaded_total, total_bytes, 0, None)?; + } + + let _ = session; + Ok(()) +} + +#[derive(Debug, serde::Deserialize)] +struct TransferSessionDto { + token: String, +} + +async fn create_transfer_session(params: &PeerDownloadParams) -> Result { + let ctx = resolve_api_context()?; + + let body = serde_json::json!({ + "targetUserId": params.target_user_id, + "targetDeviceId": params.target_device_id, + "gameKey": params.game_key, + "manifestHash": params.manifest_hash, + }); + + let url = format!("{}/inventory/transfer-sessions", ctx.base_url); + let res = API_CLIENT + .post(&url) + .header("x-api-key", &ctx.api_key) + .header("x-user-id", &ctx.user_id) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + let text = res.text().await.unwrap_or_default(); + return Err(format!("Sesión transferencia: {text}")); + } + + res.json::() + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/peer_lan/server.rs b/apps/desktop/src-tauri/src/peer_lan/server.rs new file mode 100644 index 00000000..efa3d0ad --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_lan/server.rs @@ -0,0 +1,207 @@ +//! Servidor HTTP LAN para servir archivos del inventario local. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use axum::{ + extract::{Path as AxumPath, Request, State}, + http::{header, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use tokio::fs::File; +use tokio::io::AsyncSeekExt; +use tokio_util::io::ReaderStream; + +use crate::peer_inventory::{load_local_manifest, resolve_install_root}; +use crate::peer_lan::session::peek_valid_session; + +const CHUNK_SIZE: usize = 512 * 1024; + +#[derive(Clone)] +struct LanServerState { + root_paths: Arc>, + cancel: Arc, +} + +static ACTIVE_SERVER: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(|| std::sync::Mutex::new(None)); + +pub async fn stop_lan_server() { + if let Ok(mut guard) = ACTIVE_SERVER.lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } +} + +pub async fn start_lan_server_for_session(token: &str, game_key: &str) -> Result { + stop_lan_server().await; + + let session = peek_valid_session(token) + .ok_or_else(|| "Sesión de transferencia inválida o expirada".to_string())?; + if session.game_key != game_key { + return Err("gameKey no coincide con la sesión".to_string()); + } + + let manifest = load_local_manifest()?.ok_or_else(|| "Sin manifiesto local".to_string())?; + let game = manifest + .games + .iter() + .find(|g| g.game_key == game_key) + .ok_or_else(|| "Juego no encontrado en inventario local".to_string())?; + + if game.manifest_hash != session.manifest_hash { + return Err("manifestHash no coincide".to_string()); + } + + let mut roots = std::collections::HashMap::new(); + if game.payload_kind == "installedFolder" { + let library = crate::config::load_library(); + for g in &library.games { + if crate::peer_inventory::game_key_for_configured_game(g).as_deref() == Some(game_key) { + if let Some(root) = resolve_install_root(g, game_key) { + roots.insert(game_key.to_string(), root); + break; + } + } + } + } else if game.payload_kind == "sourcesArchive" { + if let Some(ref archive) = game.sources_archive { + let jobs = crate::sources::store::load_jobs().unwrap_or_default(); + if let Some(job) = jobs.iter().find(|j| j.job_id == archive.job_id) { + let root = PathBuf::from(&job.destination_dir); + if root.is_dir() { + roots.insert(game_key.to_string(), root); + } + } + } + } + + if roots.is_empty() { + return Err("No se encontró ruta local para servir el juego".to_string()); + } + + let cancel = Arc::new(AtomicBool::new(false)); + let state = LanServerState { + root_paths: Arc::new(roots), + cancel: cancel.clone(), + }; + + let app = Router::new() + .route("/files/{*file_path}", get(serve_file)) + .route("/health", get(|| async { "ok" })) + .layer(middleware::from_fn(auth_middleware)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:0") + .await + .map_err(|e| format!("No se pudo abrir puerto LAN: {e}"))?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + + register_mdns_service(&manifest.device_id, &manifest.user_id, port)?; + + let handle = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app).await { + log::warn!("Servidor LAN finalizado: {e}"); + } + }); + + if let Ok(mut guard) = ACTIVE_SERVER.lock() { + *guard = Some(handle); + } + + Ok(port) +} + +fn register_mdns_service(device_id: &str, user_id: &str, port: u16) -> Result<(), String> { + let service = mdns_sd::ServiceDaemon::new().map_err(|e| format!("mDNS: {e}"))?; + let host = gethostname::gethostname().to_string_lossy().into_owned(); + let instance = format!("savecloud-{device_id}"); + let mut properties = std::collections::HashMap::new(); + properties.insert("deviceId".to_string(), device_id.to_string()); + properties.insert("userId".to_string(), user_id.to_string()); + + let info = mdns_sd::ServiceInfo::new( + "_savecloud._tcp.local.", + &instance, + &format!("{host}.local."), + "", + port, + properties, + ) + .map_err(|e| format!("mDNS ServiceInfo: {e}"))?; + + service + .register(info) + .map_err(|e| format!("mDNS register: {e}"))?; + Ok(()) +} + +async fn auth_middleware(request: Request, next: Next) -> Result { + if request.uri().path() == "/health" { + return Ok(next.run(request).await); + } + let auth = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let token = auth.strip_prefix("Bearer ").unwrap_or(""); + if peek_valid_session(token).is_some() { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +async fn serve_file( + State(state): State, + AxumPath(file_path): AxumPath, +) -> Result { + if state.cancel.load(Ordering::Relaxed) { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + let rel = file_path.replace('\\', "/"); + if rel.contains("..") { + return Err(StatusCode::BAD_REQUEST); + } + + let (_game_key, root) = state + .root_paths + .iter() + .next() + .ok_or(StatusCode::NOT_FOUND)?; + + let full = root.join(&rel); + if !full.is_file() { + return Err(StatusCode::NOT_FOUND); + } + + let mut file = File::open(&full).await.map_err(|_| StatusCode::NOT_FOUND)?; + let len = file + .metadata() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .len(); + file.seek(std::io::SeekFrom::Start(0)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let stream = ReaderStream::with_capacity(file, CHUNK_SIZE); + let body = axum::body::Body::from_stream(stream); + let len_header = len.to_string(); + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_LENGTH, len_header.as_str()), + ], + body, + ) + .into_response()) +} diff --git a/apps/desktop/src-tauri/src/peer_lan/session.rs b/apps/desktop/src-tauri/src/peer_lan/session.rs new file mode 100644 index 00000000..2ed5c40c --- /dev/null +++ b/apps/desktop/src-tauri/src/peer_lan/session.rs @@ -0,0 +1,45 @@ +//! Sesiones de transferencia LAN pendientes (token corto). + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use once_cell::sync::Lazy; + +#[derive(Clone)] +pub struct PendingTransferSession { + pub token: String, + pub game_key: String, + pub manifest_hash: String, + pub expires_at: Instant, +} + +static SESSIONS: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +pub fn register_transfer_session(session: PendingTransferSession) { + if let Ok(mut guard) = SESSIONS.lock() { + guard.retain(|_, s| s.expires_at > Instant::now()); + guard.insert(session.token.clone(), session); + } +} + +pub fn peek_valid_session(token: &str) -> Option { + let guard = SESSIONS.lock().ok()?; + let entry = guard.get(token)?.clone(); + if entry.expires_at <= Instant::now() { + return None; + } + Some(entry) +} + +pub fn session_ttl_from_iso(expires_at: &str) -> Duration { + chrono::DateTime::parse_from_rfc3339(expires_at) + .ok() + .and_then(|dt| { + let target = dt.with_timezone(&chrono::Utc); + let now = chrono::Utc::now(); + target.signed_duration_since(now).to_std().ok() + }) + .unwrap_or(Duration::from_secs(300)) +} diff --git a/apps/desktop/src-tauri/src/setup.rs b/apps/desktop/src-tauri/src/setup.rs index ccb4b1cf..6ab0d094 100644 --- a/apps/desktop/src-tauri/src/setup.rs +++ b/apps/desktop/src-tauri/src/setup.rs @@ -224,5 +224,7 @@ pub fn init_states_and_background_tasks(app: &mut App) -> Result<(), Box Result { + let state = app.state::(); + let job_id = new_job_id(); + let now = now_iso(); + let peer_uri = serde_json::json!({ + "gameKey": game_key, + "targetUserId": target_user_id, + "targetDeviceId": target_device_id, + "manifestHash": manifest_hash, + }) + .to_string(); + + let job = SourceDownloadJob { + job_id: job_id.clone(), + source_id: "peer-lan".to_string(), + item_id: game_key.clone(), + title, + destination_dir, + selected_uri: peer_uri, + protocol: DownloadProtocol::PeerLan, + status: SourceJobStatus::Queued, + loaded: 0, + total: 0, + download_speed_bytes: 0, + eta_seconds: None, + error: None, + external_id: Some(target_device_id), + output_file_name: None, + created_at: now.clone(), + updated_at: now, + }; + + state.upsert_job(job.clone())?; + events::emit_progress(&app, &job); + spawn_job(app, job_id.clone()); + Ok(job_id) +} + /// Cancela un job de descarga en curso. #[tauri::command] pub async fn cancel_source_download( diff --git a/apps/desktop/src-tauri/src/sources/domain.rs b/apps/desktop/src-tauri/src/sources/domain.rs index 5376927a..e9c0e538 100644 --- a/apps/desktop/src-tauri/src/sources/domain.rs +++ b/apps/desktop/src-tauri/src/sources/domain.rs @@ -102,6 +102,8 @@ pub enum DownloadProtocol { TorrentFile, /// Protocolo no identificado. Unknown, + /// Transferencia desde peer en LAN. + PeerLan, } /// Estado de ejecución de un job de descarga. diff --git a/apps/desktop/src-tauri/src/sources/queue.rs b/apps/desktop/src-tauri/src/sources/queue.rs index 590e6516..23a64a56 100644 --- a/apps/desktop/src-tauri/src/sources/queue.rs +++ b/apps/desktop/src-tauri/src/sources/queue.rs @@ -184,6 +184,7 @@ async fn run_job(app: &AppHandle, state: &SourcesState, job_id: &str) -> Result< emit_progress(app, &done_job); emit_terminal(app, &done_job); state.remove_job(job_id)?; + spawn_inventory_rescan_after_download(); } Err(err) if err == "stopped_by_user" => { let current = find_job(state, job_id)?; @@ -240,6 +241,73 @@ async fn run_job(app: &AppHandle, state: &SourcesState, job_id: &str) -> Result< emit_progress(app, &running); spawn_torrent_monitor(app.clone(), job_id.to_string(), start.info_hash); } + DownloadProtocol::PeerLan => { + let cancel_flag = state.create_cancel_flag(job_id); + let peer_meta: serde_json::Value = + serde_json::from_str(&job.selected_uri).map_err(|e| format!("Meta peer: {e}"))?; + let game_key = peer_meta["gameKey"] + .as_str() + .ok_or_else(|| "gameKey ausente".to_string())? + .to_string(); + let target_user_id = peer_meta["targetUserId"] + .as_str() + .ok_or_else(|| "targetUserId ausente".to_string())? + .to_string(); + let target_device_id = peer_meta["targetDeviceId"] + .as_str() + .ok_or_else(|| "targetDeviceId ausente".to_string())? + .to_string(); + let manifest_hash = peer_meta["manifestHash"] + .as_str() + .ok_or_else(|| "manifestHash ausente".to_string())? + .to_string(); + + let params = crate::peer_lan::PeerDownloadParams { + game_key, + destination_dir: job.destination_dir.clone(), + target_user_id, + target_device_id, + manifest_hash, + }; + + let result = crate::peer_lan::run_peer_download( + params, + cancel_flag, + |loaded, total, download_speed_bytes, eta_seconds| { + let mut current = find_job(state, job_id)?; + current.loaded = loaded; + current.total = total; + current.download_speed_bytes = download_speed_bytes; + current.eta_seconds = eta_seconds; + current.updated_at = now_iso(); + state.upsert_job(current.clone())?; + emit_progress(app, ¤t); + Ok(()) + }, + ) + .await; + + match result { + Ok(()) => { + let mut done_job = find_job(state, job_id)?; + done_job.status = SourceJobStatus::Completed; + done_job.updated_at = now_iso(); + state.upsert_job(done_job.clone())?; + emit_progress(app, &done_job); + emit_terminal(app, &done_job); + state.remove_job(job_id)?; + spawn_inventory_rescan_after_download(); + } + Err(err) if err == "stopped_by_user" => { + let current = find_job(state, job_id)?; + if current.status == SourceJobStatus::Cancelled { + emit_terminal(app, ¤t); + } + return Err(err); + } + Err(err) => return Err(err), + } + } DownloadProtocol::Unknown => return Err("Protocolo no soportado".to_string()), } @@ -296,6 +364,12 @@ pub fn cancel_job(state: &SourcesState, job_id: &str) { state.cancel(job_id); } +fn spawn_inventory_rescan_after_download() { + tauri::async_runtime::spawn(async { + let _ = crate::peer_inventory::publish_local_inventory(true).await; + }); +} + fn find_job(state: &SourcesState, job_id: &str) -> Result { state .list_jobs() diff --git a/apps/desktop/src-tauri/src/steam/mod.rs b/apps/desktop/src-tauri/src/steam/mod.rs index 066a7b18..07667de0 100644 --- a/apps/desktop/src-tauri/src/steam/mod.rs +++ b/apps/desktop/src-tauri/src/steam/mod.rs @@ -8,7 +8,9 @@ pub mod appdetails; mod path_resolver; pub mod steam_search; -pub use path_resolver::{get_steam_path_to_appid_map, resolve_app_id_for_game}; +pub use path_resolver::{ + get_steam_path_to_appid_map, resolve_app_id_for_game, resolve_steam_install_dir, +}; #[cfg(target_os = "windows")] pub use path_resolver::resolve_steam_app_id_from_map; diff --git a/apps/desktop/src-tauri/src/steam/path_resolver.rs b/apps/desktop/src-tauri/src/steam/path_resolver.rs index f871e3ca..25d6dc93 100644 --- a/apps/desktop/src-tauri/src/steam/path_resolver.rs +++ b/apps/desktop/src-tauri/src/steam/path_resolver.rs @@ -252,6 +252,19 @@ pub fn resolve_steam_app_id_from_map( } } +/// Busca la carpeta de instalación en `steamapps/common` para un App ID. +pub fn resolve_steam_install_dir(app_id: &str) -> Option { + let app_id = app_id.trim(); + if app_id.is_empty() { + return None; + } + + get_steam_path_to_appid_map() + .into_iter() + .find(|(_, id)| id == app_id) + .map(|(path, _)| path) +} + /// Resuelve el Steam AppID para un juego. pub fn resolve_app_id_for_game( game_paths: &[String], diff --git a/apps/desktop/src/features/game-detail/GameDetailPage.tsx b/apps/desktop/src/features/game-detail/GameDetailPage.tsx index 9e0e491d..964943a2 100644 --- a/apps/desktop/src/features/game-detail/GameDetailPage.tsx +++ b/apps/desktop/src/features/game-detail/GameDetailPage.tsx @@ -25,7 +25,9 @@ import { syncCheckDownloadConflicts, } from "@services/tauri"; import type { DownloadConflict } from "@services/tauri"; -import { sourcesFindMatchForGame, startSourceDownload } from "@services/tauri"; +import { sourcesFindMatchForGame, startSourceDownload, startPeerGameDownload } from "@services/tauri"; +import type { PeerInstallOffer } from "@services/tauri/inventory.service"; +import { usePeerInstallOffers } from "@hooks/usePeerInstallOffers"; import { createShareLink } from "@/services/tauri/share.service"; import { toastError, toastSuccess } from "@utils/toast"; import { CONFIG_QUERY_KEY } from "@hooks/useConfig"; @@ -99,6 +101,11 @@ export function GameDetailPage() { protocols?: string[]; } | null>(null); + const peerOffersHook = usePeerInstallOffers( + steamAppId ?? game?.steamAppId, + isInstallModalOpen && !!installingFromSource + ); + useLayoutEffect(() => { window.scrollTo({ top: 0, behavior: "instant" }); }, []); @@ -302,6 +309,27 @@ export function GameDetailPage() { [sourceCandidates, selectedSourceKey, displayName] ); + const handleConfirmPeerInstall = useCallback( + async (selectedPath: string, offer: PeerInstallOffer) => { + if (!peerOffersHook.gameKey) return; + + try { + await startPeerGameDownload({ + gameKey: peerOffersHook.gameKey, + title: displayName, + destinationDir: selectedPath.trim(), + targetUserId: offer.userId, + targetDeviceId: offer.deviceId, + manifestHash: offer.manifestHash, + }); + toastSuccess("Transferencia iniciada", `Traiendo ${displayName} desde ${offer.deviceName}.`); + } catch (e) { + toastError("No se pudo transferir", e instanceof Error ? e.message : String(e)); + } + }, + [peerOffersHook.gameKey, displayName] + ); + if (isLoading) { return (
@@ -560,7 +588,11 @@ export function GameDetailPage() { protocols={installingFromSource.protocols} game={game} mediaBySteamAppId={installModalMediaBySteamAppId} + peerOffers={peerOffersHook.offers} + selectedPeerDeviceId={peerOffersHook.selectedDeviceId} + onSelectPeerDevice={peerOffersHook.setSelectedDeviceId} onConfirm={handleConfirmInstall} + onConfirmPeer={handleConfirmPeerInstall} /> ) : null}
diff --git a/apps/desktop/src/features/settings/CloudDashboardPanel.tsx b/apps/desktop/src/features/settings/CloudDashboardPanel.tsx index ea52d020..2f68a820 100644 --- a/apps/desktop/src/features/settings/CloudDashboardPanel.tsx +++ b/apps/desktop/src/features/settings/CloudDashboardPanel.tsx @@ -28,6 +28,7 @@ import { formatGameDisplayName } from "@utils/gameImage"; import { formatLastSync, formatRelativeDate, formatSize } from "@utils/format"; import type { ConfiguredGame } from "@app-types/config"; import type { SteamAppdetailsMediaResult } from "@services/tauri"; +import { GameInventorySettingsCard } from "@features/settings/GameInventorySettingsCard"; const MAIN_WEBVIEW_LABEL = "main"; @@ -145,6 +146,8 @@ export function CloudDashboardPanel({ onSelectAccountTab }: CloudDashboardPanelP return (
+ +

Resumen de la nube y detalle por juego.

+
+ + + ); +} diff --git a/apps/desktop/src/features/steam-catalog/components/InstallModal.tsx b/apps/desktop/src/features/steam-catalog/components/InstallModal.tsx index 972fa83d..d7fe8e0c 100644 --- a/apps/desktop/src/features/steam-catalog/components/InstallModal.tsx +++ b/apps/desktop/src/features/steam-catalog/components/InstallModal.tsx @@ -13,6 +13,7 @@ import { useDisks } from "@hooks/useDisks"; import { formatBytes } from "@utils/format"; import { open } from "@tauri-apps/plugin-dialog"; import { parseSize } from "@utils/size"; +import type { PeerInstallOffer } from "@services/tauri/inventory.service"; import { InstallModalGameCover } from "@features/steam-catalog/components/InstallModalGameCover"; export interface InstallModalProps { @@ -24,7 +25,11 @@ export interface InstallModalProps { mediaBySteamAppId?: Record | null; /** Protocolos disponibles del ítem; define el método mostrado (torrent vs HTTP). */ protocols?: readonly string[] | null; + peerOffers?: PeerInstallOffer[]; + selectedPeerDeviceId?: string | null; + onSelectPeerDevice?: (deviceId: string) => void; onConfirm: (path: string) => void; + onConfirmPeer?: (path: string, offer: PeerInstallOffer) => void; } const DEFAULT_DOWNLOAD_SUBFOLDER = "SaveCloudGames"; @@ -37,7 +42,11 @@ export function InstallModal({ game, mediaBySteamAppId, protocols, + peerOffers = [], + selectedPeerDeviceId, + onSelectPeerDevice, onConfirm, + onConfirmPeer, }: InstallModalProps) { const { disks } = useDisks(); const [selectedDisk, setSelectedDisk] = useState(null); @@ -99,11 +108,21 @@ export function InstallModal({ return null; }, [customPath, selectedDisk, gameName]); + const selectedPeer = useMemo( + () => peerOffers.find((o) => o.deviceId === selectedPeerDeviceId) ?? peerOffers[0] ?? null, + [peerOffers, selectedPeerDeviceId] + ); + + const peerReachable = selectedPeer?.reachableOnLan === true; + const handleInstall = () => { - if (effectivePath) { + if (!effectivePath) return; + if (peerReachable && selectedPeer && onConfirmPeer) { + onConfirmPeer(effectivePath, selectedPeer); + } else { onConfirm(effectivePath); - onOpenChange(false); } + onOpenChange(false); }; return ( @@ -184,7 +203,7 @@ export function InstallModal({
)} - + {disks.map((disk) => { const isSelected = selectedDisk === disk.mountPoint && !customPath; const lowSpace = disk.availableSpace < gameSizeBytes; @@ -248,6 +267,32 @@ export function InstallModal({ ); })} + + {peerOffers.length > 0 && selectedPeer ? ( +
+

Nota

+

+ {peerReachable + ? `Este juego se puede transferir en la red local desde el dispositivo ${selectedPeer.deviceName.toUpperCase()}.` + : `Este juego se puede transferir en la red local (para ahorrar ancho de banda) si enciendes el dispositivo ${selectedPeer.deviceName.toUpperCase()}.`} +

+ {peerOffers.length > 1 && onSelectPeerDevice ? ( +
+ {peerOffers.map((offer) => ( + + ))} +
+ ) : null} +
+ ) : null} @@ -257,10 +302,10 @@ export function InstallModal({ diff --git a/apps/desktop/src/features/steam-catalog/components/SteamCatalogGrid.tsx b/apps/desktop/src/features/steam-catalog/components/SteamCatalogGrid.tsx index 6a030dcd..6072c5c8 100644 --- a/apps/desktop/src/features/steam-catalog/components/SteamCatalogGrid.tsx +++ b/apps/desktop/src/features/steam-catalog/components/SteamCatalogGrid.tsx @@ -6,7 +6,9 @@ import { GameCard } from "@features/games/GameCard"; import { GamesListMotionContainer, GamesListMotionItem } from "@features/games/GamesListMotion"; import { catalogListItemToConfiguredGame } from "@features/steam-catalog/model/catalogConfiguredGame"; import { Button, Select, SelectItem } from "@heroui/react"; -import { startSourceDownload } from "@services/tauri"; +import { startPeerGameDownload, startSourceDownload } from "@services/tauri"; +import type { PeerInstallOffer } from "@services/tauri/inventory.service"; +import { usePeerInstallOffers } from "@hooks/usePeerInstallOffers"; import { pickCandidate, sourceCandidateKey } from "@utils/sourceMatch"; import { toastError, toastSuccess } from "@utils/toast"; import { useLocation, useNavigate } from "react-router-dom"; @@ -177,6 +179,8 @@ export function SteamCatalogGrid({ chosen: SourceBestMatch; } | null>(null); + const peerOffersHook = usePeerInstallOffers(installingGame?.game.steamAppId, isOpen && !!installingGame); + const matchByGameNameRef = useRef(matchByGameName); const pickByGameRef = useRef(pickByGame); useEffect(() => { @@ -259,6 +263,29 @@ export function SteamCatalogGrid({ [installingGame] ); + const handleConfirmPeerInstall = useCallback( + async (selectedPath: string, offer: PeerInstallOffer) => { + if (!installingGame || !peerOffersHook.gameKey) return; + const { name } = installingGame; + + try { + await startPeerGameDownload({ + gameKey: peerOffersHook.gameKey, + title: name, + destinationDir: selectedPath.trim(), + targetUserId: offer.userId, + targetDeviceId: offer.deviceId, + manifestHash: offer.manifestHash, + }); + + toastSuccess("Transferencia iniciada", `Traiendo ${name} desde ${offer.deviceName}.`); + } catch (e) { + toastError("No se pudo transferir", e instanceof Error ? e.message : String(e)); + } + }, + [installingGame, peerOffersHook.gameKey] + ); + return ( <> )} diff --git a/apps/desktop/src/hooks/usePeerInstallOffers.ts b/apps/desktop/src/hooks/usePeerInstallOffers.ts new file mode 100644 index 00000000..1598d562 --- /dev/null +++ b/apps/desktop/src/hooks/usePeerInstallOffers.ts @@ -0,0 +1,41 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { resolvePeerInstallOffers } from "@services/tauri/inventory.service"; + +export const PEER_INSTALL_OFFERS_QUERY_KEY = ["peer-install-offers"] as const; + +export function usePeerInstallOffers(steamAppId: string | null | undefined, enabled: boolean) { + const trimmedId = steamAppId?.trim() ?? ""; + const queryEnabled = enabled && trimmedId.length > 0; + + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: [...PEER_INSTALL_OFFERS_QUERY_KEY, trimmedId], + queryFn: () => resolvePeerInstallOffers(trimmedId), + enabled: queryEnabled, + staleTime: 15_000, + refetchOnWindowFocus: false, + }); + + const [selectedDeviceId, setSelectedDeviceId] = useState(null); + + const offers = data?.offers ?? []; + const gameKey = data?.gameKey ?? null; + + const selectedOffer = useMemo(() => { + if (selectedDeviceId) { + const picked = offers.find((o) => o.deviceId === selectedDeviceId); + if (picked) return picked; + } + return offers.find((o) => o.reachableOnLan) ?? offers[0] ?? null; + }, [offers, selectedDeviceId]); + + return { + offers, + gameKey, + loading: isLoading || isFetching, + selectedDeviceId: selectedOffer?.deviceId ?? null, + setSelectedDeviceId, + selectedOffer, + refresh: refetch, + }; +} diff --git a/apps/desktop/src/services/tauri/index.ts b/apps/desktop/src/services/tauri/index.ts index 121d053d..c0f66d47 100644 --- a/apps/desktop/src/services/tauri/index.ts +++ b/apps/desktop/src/services/tauri/index.ts @@ -4,6 +4,7 @@ export * from "./compat.service"; export * from "./gamification.service"; export * from "./updater.service"; export * from "./invites.service"; +export * from "./inventory.service"; export * from "./sources.service"; export * from "./share.service"; export * from "./profile.service"; diff --git a/apps/desktop/src/services/tauri/inventory.service.ts b/apps/desktop/src/services/tauri/inventory.service.ts new file mode 100644 index 00000000..afc37c0b --- /dev/null +++ b/apps/desktop/src/services/tauri/inventory.service.ts @@ -0,0 +1,132 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface InventoryFileEntry { + relativePath: string; + size: number; + hash: string; +} + +export interface GameInventoryEntry { + gameKey: string; + displayName: string; + status: string; + payloadKind: string; + totalBytes: number; + fileCount: number; + manifestHash: string; + verifiedAt: string; + files: InventoryFileEntry[]; +} + +export interface DeviceInventoryManifest { + deviceId: string; + deviceName: string; + userId: string; + manifestVersion: number; + contentHash: string; + updatedAt: string; + sharingEnabled: boolean; + games: GameInventoryEntry[]; +} + +export interface GameProviderDevice { + userId: string; + deviceId: string; + deviceName: string; + totalBytes: number; + payloadKind: string; + manifestHash: string; + verifiedAt: string; + lastSeenAt?: string | null; + files?: InventoryFileEntry[]; +} + +export interface GameProvidersResponse { + gameKey: string; + providers: GameProviderDevice[]; +} + +export interface LanDeviceProbe { + deviceId: string; + userId: string; + lanHost: string; + port: number; + reachable: boolean; +} + +export interface PeerInstallOffer { + userId: string; + deviceId: string; + deviceName: string; + totalBytes: number; + payloadKind: string; + manifestHash: string; + reachableOnLan: boolean; +} + +export function inventoryGameKeyFromSteamAppId(steamAppId: string): Promise { + return invoke("inventory_game_key_from_steam_app_id", { steamAppId }); +} + +export function inventoryListProviders(gameKey: string): Promise { + return invoke("inventory_list_providers", { gameKey }); +} + +export function inventoryProbeLan(deviceIds: string[]): Promise { + return invoke("inventory_probe_lan", { deviceIds }); +} + +export function inventoryScanAndPublish(forceScan = true): Promise { + return invoke("inventory_scan_and_publish", { forceScan }); +} + +export function inventoryGetLocal(): Promise<{ manifest: DeviceInventoryManifest | null }> { + return invoke<{ manifest: DeviceInventoryManifest | null }>("inventory_get_local"); +} + +export function startPeerGameDownload(params: { + gameKey: string; + title: string; + destinationDir: string; + targetUserId: string; + targetDeviceId: string; + manifestHash: string; +}): Promise { + return invoke("start_peer_game_download", params); +} + +export function setShareGameInventoryWithCloud(enabled: boolean): Promise { + return invoke("set_share_game_inventory_with_cloud", { enabled }); +} + +export async function resolvePeerInstallOffers(steamAppId: string | null | undefined): Promise<{ + gameKey: string | null; + offers: PeerInstallOffer[]; +}> { + if (!steamAppId?.trim()) { + return { gameKey: null, offers: [] }; + } + + const gameKey = await inventoryGameKeyFromSteamAppId(steamAppId); + if (!gameKey) { + return { gameKey: null, offers: [] }; + } + + const [providersRes, _] = await Promise.all([inventoryListProviders(gameKey), Promise.resolve(null)]); + + const deviceIds = providersRes.providers.map((p) => p.deviceId); + const lanProbes = deviceIds.length > 0 ? await inventoryProbeLan(deviceIds) : []; + const probeByDevice = new Map(lanProbes.map((p) => [p.deviceId, p])); + + const offers: PeerInstallOffer[] = providersRes.providers.map((p) => ({ + userId: p.userId, + deviceId: p.deviceId, + deviceName: p.deviceName, + totalBytes: p.totalBytes, + payloadKind: p.payloadKind, + manifestHash: p.manifestHash, + reachableOnLan: probeByDevice.get(p.deviceId)?.reachable ?? false, + })); + + return { gameKey, offers }; +} diff --git a/apps/desktop/src/services/tauri/sources.service.ts b/apps/desktop/src/services/tauri/sources.service.ts index 35f981f1..31bd4aff 100644 --- a/apps/desktop/src/services/tauri/sources.service.ts +++ b/apps/desktop/src/services/tauri/sources.service.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; -export type DownloadProtocol = "http" | "torrentMagnet" | "torrentFile" | "unknown"; +export type DownloadProtocol = "http" | "torrentMagnet" | "torrentFile" | "peerLan" | "unknown"; export type SourceJobStatus = "queued" | "running" | "paused" | "cancelled" | "completed" | "failed"; export type ImportMode = "merge" | "replace" | "updateorcreate"; diff --git a/apps/desktop/src/types/config.ts b/apps/desktop/src/types/config.ts index e17f5b99..9294e43a 100644 --- a/apps/desktop/src/types/config.ts +++ b/apps/desktop/src/types/config.ts @@ -63,6 +63,8 @@ export interface Config { readonly shareVisualProfileWithHosts?: boolean; /** Si es true, los miembros de tu nube pueden ver tu perfil visual al cargar tu usuario. */ readonly shareVisualProfileWithMembers?: boolean; + /** Si es true, compartes qué juegos tienes instalados con miembros del cloud (opt-out). */ + readonly shareGameInventoryWithCloud?: boolean; /** Modo juego activo en SaveCloud (mitigaciones conservadoras). */ readonly gameModeEnabled?: boolean; /** Aplicar perfil de alto rendimiento (Windows powercfg Alto rendimiento, Linux performance, macOS caffeinate cuando hay soporte). */ diff --git a/apps/desktop/src/utils/sourceMatch.ts b/apps/desktop/src/utils/sourceMatch.ts index 79903801..0a280c84 100644 --- a/apps/desktop/src/utils/sourceMatch.ts +++ b/apps/desktop/src/utils/sourceMatch.ts @@ -4,7 +4,7 @@ import type { DownloadProtocol, SourceBestMatch } from "@services/tauri"; const SEP = "|||"; /** Método de descarga que usará `start_source_download` sin `preferredProtocol`. */ -export type EffectiveDownloadKind = "http" | "torrent" | "unknown"; +export type EffectiveDownloadKind = "http" | "torrent" | "peerLan" | "unknown"; /** Replica la prioridad de `start_source_download` en Rust: torrent antes que HTTP. */ export function resolveDefaultDownloadKind( @@ -24,6 +24,8 @@ export function downloadKindLabel(kind: EffectiveDownloadKind): string { return "BitTorrent"; case "http": return "HTTP"; + case "peerLan": + return "Transferencia LAN"; default: return "Desconocido"; } @@ -35,6 +37,8 @@ export function downloadKindDescription(kind: EffectiveDownloadKind): string { return "Descarga P2P con el motor integrado (magnet o .torrent)."; case "http": return "Descarga directa desde el hoster del enlace."; + case "peerLan": + return "Copia desde otro miembro del cloud en tu red local."; default: return "No se pudo determinar el método de descarga."; }