diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aae2b92..3320dfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,97 @@ jobs: - name: Run tests (Release) run: dotnet test SteamAPI.sln --configuration Release --no-build --no-restore + + - name: Validate Desktop Packaging (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + ./desktop/package-release.ps1 + if (-not (Test-Path "artifacts/releases/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe")) { + throw "Expected installer artifacts/releases/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe was not found." + } + if (-not (Test-Path "artifacts/releases/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe.sha256")) { + throw "Expected checksum file artifacts/releases/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe.sha256 was not found." + } + if (-not (Test-Path "artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip")) { + throw "Expected release archive artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip was not found." + } + if (-not (Test-Path "artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip.sha256")) { + throw "Expected checksum file artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip.sha256 was not found." + } + + - name: Upload Windows Desktop Artifacts + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: steamapi-desktop-v0.1.0-win-x64 + path: | + artifacts/releases/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe + artifacts/releases/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe.sha256 + artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip + artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip.sha256 + if-no-files-found: error + + publish-desktop-v0_1_0: + name: Publish SteamAPI Desktop v0.1.0 + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/master' && + startsWith(github.event.head_commit.message, 'release(desktop): SteamAPI Desktop v0.1.0 (#4)') + needs: build-and-test + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download verified Windows installation packages + uses: actions/download-artifact@v4 + with: + name: steamapi-desktop-v0.1.0-win-x64 + path: release-assets + + - name: Validate checksums before publication + shell: bash + run: | + cd release-assets + for file in SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe SteamAPI-Desktop-v0.1.0-win-x64.zip; do + test -s "$file" + test -s "$file.sha256" + expected=$(head -c 64 "$file.sha256" | tr '[:upper:]' '[:lower:]') + actual=$(sha256sum "$file" | cut -d ' ' -f 1) + if [ "$expected" != "$actual" ]; then echo "Checksum mismatch: $file" >&2; exit 1; fi + done + + - name: Create verified GitHub Release (draft then publish) + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if git ls-remote --tags https://github.com/${{ github.repository }}.git refs/tags/steamapi-desktop-v0.1.0 | grep -q .; then + echo 'Release tag already exists; refusing to overwrite it.' >&2 + exit 1 + fi + cat > release-notes.md <<'EOF' + # SteamAPI Desktop v0.1.0 + + First Windows x64 desktop release. The installer is the recommended download. + + **Recommended:** `SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe` — standard Windows per-user installation with shortcuts and uninstaller. + + **Alternative:** `SteamAPI-Desktop-v0.1.0-win-x64.zip` — portable version without installation. + + Includes game search using the existing six-field SteamAPI backend contract, API Inspector, local companion API management, Settings, offline Demo Mode and About. The v0.2.0 backend API remains unchanged. + + The installer is unsigned; Windows SmartScreen may show an Unknown Publisher warning. Compatible with Windows x64 and includes the .NET 10 runtime. + EOF + gh release create steamapi-desktop-v0.1.0 \ + release-assets/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe \ + release-assets/SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe.sha256 \ + release-assets/SteamAPI-Desktop-v0.1.0-win-x64.zip \ + release-assets/SteamAPI-Desktop-v0.1.0-win-x64.zip.sha256 \ + --draft --latest=false --target "$GITHUB_SHA" \ + --title 'SteamAPI Desktop v0.1.0' --notes-file release-notes.md + count=$(gh release view steamapi-desktop-v0.1.0 --json assets --jq '.assets | length') + if [ "$count" -ne 4 ]; then echo "Expected 4 release assets, found $count" >&2; exit 1; fi + gh release edit steamapi-desktop-v0.1.0 --draft=false diff --git a/SteamAPI.sln b/SteamAPI.sln index 7cd75f7..037fd44 100644 --- a/SteamAPI.sln +++ b/SteamAPI.sln @@ -11,6 +11,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamAPI.Tests", "SteamAPI. EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructer", "Infrastructer", "{C5042FDC-E459-6CA1-E6FF-61BB0E5E0C16}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "desktop", "desktop", "{7A1224A0-F22D-34FF-DE0A-A058FAFFD90C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamAPI.Desktop", "desktop\SteamAPI.Desktop\SteamAPI.Desktop.csproj", "{B900B726-06CC-40F2-890A-DEE91FF6B7A4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamAPI.Desktop.Tests", "desktop\SteamAPI.Desktop.Tests\SteamAPI.Desktop.Tests.csproj", "{BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,10 +63,38 @@ Global {0BF7D4D2-7B2E-41D4-B8ED-868F26D9C397}.Release|x64.Build.0 = Release|Any CPU {0BF7D4D2-7B2E-41D4-B8ED-868F26D9C397}.Release|x86.ActiveCfg = Release|Any CPU {0BF7D4D2-7B2E-41D4-B8ED-868F26D9C397}.Release|x86.Build.0 = Release|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Debug|x64.Build.0 = Debug|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Debug|x86.Build.0 = Debug|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Release|Any CPU.Build.0 = Release|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Release|x64.ActiveCfg = Release|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Release|x64.Build.0 = Release|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Release|x86.ActiveCfg = Release|Any CPU + {B900B726-06CC-40F2-890A-DEE91FF6B7A4}.Release|x86.Build.0 = Release|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Debug|x64.ActiveCfg = Debug|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Debug|x64.Build.0 = Debug|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Debug|x86.Build.0 = Debug|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Release|Any CPU.Build.0 = Release|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Release|x64.ActiveCfg = Release|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Release|x64.Build.0 = Release|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Release|x86.ActiveCfg = Release|Any CPU + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B900B726-06CC-40F2-890A-DEE91FF6B7A4} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} + {BB1BF26F-70A3-4AD5-BD40-A3D3D5DBEBE2} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1EFCD427-1922-4A2C-88A9-5ACB3E0B75AD} EndGlobalSection diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..ec6c20b --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,256 @@ +# SteamAPI Desktop + +**Version:** `v0.1.0 (Release Candidate)` +**Target Runtime:** `.NET 10.0` (C# 14) +**UI Framework:** [Avalonia UI](https://avaloniaui.net/) 11.2 +**Pattern:** MVVM (via `CommunityToolkit.Mvvm`) +**Companion Backend:** [SteamAPI v0.2.0](https://github.com/fatihemres/SteamAPI) +**License:** Permissive MIT License +**Financial Model:** **$0.00 / Zero Mandatory Recurring Costs** + +--- + +## 1. Executive Summary & Product Vision + +**SteamAPI Desktop** is a standalone, lightweight, cross-platform desktop application designed to explore the Steam catalog, test regional pricing, inspect raw API telemetry, and visualize the capabilities of the `SteamAPI` backend. + +Built from the ground up on modern **.NET 10** and **Avalonia UI**, SteamAPI Desktop delivers a native, responsive dark-mode gaming aesthetic while maintaining strict compliance with the verified `SteamAPI v0.2.0` contract (`POST /steam`). + +### Key Principles +* **Zero Mandatory Recurring Costs:** Built entirely on open-source, royalty-free technologies without paid third-party components, commercial UI libraries, subscriptions, or cloud hosting dependencies. +* **Non-Invasive Architecture:** Preserves the existing ASP.NET Core `SteamAPI` project and its 157 baseline unit/integration tests without touching protected code. +* **Developer & User Friendly:** Functions both as an interactive visual consumer for everyday users and a rich API inspector for developers. +* **Zero-Configuration First Run:** Includes a supervised loopback child process manager to auto-launch the local API, alongside an instant Offline Demo Mode. + +--- + +## 2. Core Features & Capabilities + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ SteamAPI Desktop v0.1.0 │ +├───────────────┬───────────────────┬──────────────────┬─────────────────┤ +│ 🎮 Explorer │ 🔍 API Inspector │ ⚙️ Settings │ ℹ️ About & Help │ +└───────────────┴───────────────────┴──────────────────┴─────────────────┘ +``` + +### Feature 1: Game Explorer +* **Search Catalog:** Search titles by keyword with client-side input validation (length limits, non-empty checks). +* **Multi-Culture Storefront Switching:** Instantly query supported regional stores: + * 🇹🇷 **Turkey (TR / Culture 1):** Turkish storefront (Primary targeted market). + * 🇺🇸 **United States (US / Culture 2):** United States storefront (Validation & baseline market). + * 🇪🇸 **Spain (ES / Culture 3):** Spain storefront (European validation market). +* **Game Cards & Selection:** View search results with game title, App ID badge, sale type badges (`PAID`, `FREE TO PLAY`, `NOT AVAILABLE`), and verified price formatting. + +### Feature 2: Game Details Inspector +* Detailed slide-over sheet displaying: + * Full game title and App ID badge. + * Exact price formatted according to verified contract semantics. + * Sale type classification. + * Raw Steam store URL. +* **One-Click Native Launchers:** + * `🚀 Open in Steam Client`: Launches `steam://store/{appId}` directly in the local desktop Steam client. + * `🌐 View Store Webpage`: Opens the official web store page in the default system browser. + * `📋 Copy App ID`: Copies the numeric App ID to the system clipboard. + * `📋 Copy Store Link`: Copies the canonical store URL to the system clipboard. + +### Feature 3: Live HTTP API Inspector +* Dedicated telemetry tab capturing live HTTP network transactions between desktop client and API server. +* **History Log:** Retains up to 50 recent HTTP calls displaying HTTP method (`POST`), status code badges (e.g. `200 OK` green, `400 Bad Request` yellow, `502/504` red), round-trip latency (`ms`), and timestamps. +* **Payload Inspection:** Formatted JSON viewers for both request payloads and response bodies with syntax coloring and one-click clipboard copying. + +### Feature 4: Bundled Loopback Server (Child Process Manager) +* Automatically discovers and manages a local `SteamAPI` backend process. +* Binds strictly to `http://127.0.0.1:5271` to prevent unwanted network exposure. +* Performs socket-level port conflict detection. +* Graceful lifecycle management: guarantees termination of the child process tree upon desktop application exit. + +### Feature 5: Offline Demo Mode (Zero Dependencies) +* Built-in synthetic catalog simulator. +* Allows instant offline testing and evaluation without an active internet connection or local API server. +* Clearly marks synthetic items with `[SYNTHETIC DEMO]` to prevent misrepresentation. +* Simulates regional price variations across Turkey, United States, and Spain. + +--- + +## 3. Technology Stack & Architecture + +```mermaid +graph TD + UI[Avalonia UI 12.0 Desktop Window] + VM[MainWindowViewModel] + + VM --> GVM[GameExplorerViewModel] + VM --> IVM[ApiInspectorViewModel] + VM --> SVM[SettingsViewModel] + VM --> AVM[AboutViewModel] + + GVM --> Client[ISteamApiClient] + GVM --> Demo[IDemoDataService] + GVM --> Clip[IClipboardService] + GVM --> Launch[ILauncherService] + + SVM --> Process[IApiProcessManager] + SVM --> Settings[ISettingsService] + + Client -->|HTTP POST /steam| Backend[Local or Remote SteamAPI v0.2.0] + Process -->|Spawns & Monitors| Backend +``` + +| Component | Technology | Version | Purpose | +| :--- | :--- | :--- | :--- | +| **Language & Runtime** | C# 14 / .NET 10 | `net10.0` | High-performance modern language runtime | +| **Desktop UI Framework** | Avalonia UI | `12.0.4` | Cross-platform XAML UI with SkiaSharp rendering | +| **MVVM Toolkit** | CommunityToolkit.Mvvm | `8.4.1` | Source-generated MVVM observable properties & commands | +| **JSON Serialization** | System.Text.Json | Built-in | Fast, memory-efficient JSON serialization | +| **Unit Testing** | xUnit & Microsoft.NET.Test.Sdk | `2.9.3` | Automated unit, regression, and telemetry tests | + +--- + +## 4. First-Run & Quickstart Guide + +### Option A: Recommended Windows Installer (Primary Release Download) + +1. **Download:** Grab `SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe` from GitHub Releases or the Actions artifact bundle. +2. **Checksum Verification:** Verify the SHA-256 checksum against the matching `.sha256` sidecar file: + ```powershell + Get-FileHash -Algorithm SHA256 .\SteamAPI-Desktop-Setup-v0.1.0-win-x64.exe + ``` +3. **Installation:** Run the setup executable. + * **Per-User Non-Admin Setup:** Installs directly to `%LOCALAPPDATA%\Programs\SteamAPI Desktop` without requiring administrative UAC elevation. + * **Custom Path:** The destination directory can be customized during setup. + * **Start Menu & Shortcuts:** Creates a Start Menu entry (`SteamAPI Desktop`) and provides an optional desktop shortcut task. + * **SmartScreen Note:** As an open-source, royalty-free product without an expensive commercial EV code-signing certificate, Windows SmartScreen will display an "Unknown Publisher" prompt on initial run. Click **More info** -> **Run anyway**. +4. **Upgrades:** Future versions can be installed directly over existing installations; the installer uses a persistent Application ID GUID to perform safe in-place upgrades. +5. **Uninstallation:** Registered in standard Windows Settings (**Installed Apps** / **Add or Remove Programs**). The clean uninstaller removes all installed application binaries and shortcuts without touching unrelated files. +6. **User Settings Preservation:** Custom settings located at `%APPDATA%\SteamAPI.Desktop\settings.json` are strictly preserved across upgrades, reinstalls, and uninstallation. + +### Option B: Portable ZIP Archive (Advanced / Developer Alternative) + +For environments where installation is restricted or for portable USB usage: +1. Download `SteamAPI-Desktop-v0.1.0-win-x64.zip` and verify SHA-256 checksum: + ```powershell + Get-FileHash -Algorithm SHA256 .\SteamAPI-Desktop-v0.1.0-win-x64.zip + ``` +2. Extract the archive into any local folder. +3. Launch `SteamAPI.Desktop.exe` directly. + +### Option C: Building from Source +Prerequisites: [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +```powershell +# Clone the repository +git clone https://github.com/fatihemres/SteamAPI.git +cd SteamAPI + +# Restore dependencies +dotnet restore SteamAPI.sln + +# Build complete solution (backend + tests + desktop) +dotnet build SteamAPI.sln + +# Run all 222 automated tests +dotnet test SteamAPI.sln + +# Run the desktop application +dotnet run --project desktop/SteamAPI.Desktop/SteamAPI.Desktop.csproj +``` + +--- + +## 5. Regional Storefront Cultures + +`SteamAPI` v0.2.0 natively accepts three culture IDs corresponding to specific Steam regional storefronts: + +| Culture ID | Country Code | Language | Market Description | +| :---: | :---: | :---: | :--- | +| **1** | `TR` | `turkish` | **Turkey:** Primary targeted storefront market. | +| **2** | `US` | `english` | **United States:** Validation & baseline storefront market. | +| **3** | `ES` | `spanish` | **Spain:** European validation storefront market. | + +--- + +## 6. Verification & Automated Test Suite + +SteamAPI maintains 100% test pass rates across all layers: + +* **Baseline SteamAPI Tests:** 157 / 157 passing (100% green). +* **SteamAPI Desktop Tests:** 65 / 65 passing (100% green). +* **Total Executed Tests:** **222 / 222 passing** across solution. + +Test coverage for Desktop includes: +* `SteamGameListingTests`: 6-field contract serialization and display price formatting. +* `SteamCultureTests`: Regional culture enumeration and mappings. +* `DemoDataServiceTests`: Synthetic catalog querying, filtering, and regional adjustments. +* `SteamApiClientTests`: HTTP handling (200 OK, 400 Validation ProblemDetails, 502/504 errors, network exceptions, telemetry event emission). +* `GameExplorerViewModelTests`: Input validation, search dispatching, selection, clipboard and launcher interactions. +* `ApiInspectorViewModelTests`: Telemetry record collection, FIFO capping at 50 items, payload copying. +* `SettingsViewModelTests`: Settings loading, persistence, default reset, connection testing. +* `SettingsServiceTests`: Isolated JSON configuration persistence on disk. +* `MainWindowViewModelTests`: Navigation tab transitions and lifecycle process cleanup. +* `AboutViewModelTests`: Metadata and external link verification. + +--- + +## 7. Packaging & Distribution + +To create an official release package and checksum: + +```powershell +# Run the automated packaging script +.\desktop\package-release.ps1 +``` + +This generates: +* `artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip` +* `artifacts/releases/SteamAPI-Desktop-v0.1.0-win-x64.zip.sha256` + +--- + +## 8. Author & Creator Attribution + +SteamAPI and SteamAPI Desktop are created, designed, and maintained by: + +* **Creator & Author:** **Fatih Emre Sarman** +* **GitHub Profile:** [@fatihemres](https://github.com/fatihemres) +* **Official Repository:** [https://github.com/fatihemres/SteamAPI](https://github.com/fatihemres/SteamAPI) +* **Copyright:** `Copyright © 2026 Fatih Emre Sarman` + +--- + +## 9. Release Authenticity & Verification + +To verify that a downloaded binary or package originates from the official repository: + +1. **Official Release Source:** Download binaries strictly from the official GitHub repository releases at [https://github.com/fatihemres/SteamAPI/releases](https://github.com/fatihemres/SteamAPI/releases). +2. **Cryptographic Checksums:** Compare the computed SHA-256 hash of the archive against the published `.sha256` manifest: + ```powershell + Get-FileHash -Algorithm SHA256 .\SteamAPI-Desktop-v0.1.0-win-x64.zip + ``` +3. **Git Commit & Tag Verification:** Official builds correspond to signed or annotated Git release tags within `fatihemres/SteamAPI`. +4. **Integrity vs. Attribution:** While cryptographic checksums and digital signatures ensure binary payload integrity and detect unauthorized tampering, software authorship and copyright ownership are established by the public Git commit lineage and license documentation. + +--- + +## 10. Licensing & Trademarks + +### Software License +SteamAPI Desktop is proposed for release under the **MIT License**: + +```text +Copyright (c) 2026 Fatih Emre Sarman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +``` + +### Trademark Disclaimer +> **Notice:** SteamAPI and SteamAPI Desktop are independent open-source developer tools and are **not** affiliated with, authorized, maintained, sponsored, or endorsed by **Valve Corporation** or any of its affiliates. +> *Steam* and the *Steam logo* are trademarks or registered trademarks of Valve Corporation in the United States and other countries. diff --git a/desktop/SteamAPI.Desktop.Tests/AboutViewModelTests.cs b/desktop/SteamAPI.Desktop.Tests/AboutViewModelTests.cs new file mode 100644 index 0000000..4fc2191 --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/AboutViewModelTests.cs @@ -0,0 +1,73 @@ +using SteamAPI.Desktop.Services; +using SteamAPI.Desktop.ViewModels; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class AboutViewModelTests +{ + private class DummyLauncher : ILauncherService + { + public Uri? LaunchedUri { get; private set; } + public Task LaunchUriAsync(Uri uri) + { + LaunchedUri = uri; + return Task.FromResult(true); + } + } + + [Fact] + public void Properties_ExposeVersionAndLicenseInformation() + { + var launcher = new DummyLauncher(); + var vm = new AboutViewModel(launcher); + + Assert.Contains("v0.1.0", vm.AppVersion); + Assert.Contains("SteamAPI v0.2.0", vm.ApiCompatibilityVersion); + Assert.Contains(".NET 10", vm.FrameworkVersion); + Assert.Equal("MIT License", vm.LicenseType); + Assert.Contains("github.com", vm.GitHubRepoUrl); + } + + [Fact] + public async Task OpenCommands_LaunchProperUris() + { + var launcher = new DummyLauncher(); + var vm = new AboutViewModel(launcher); + + await vm.OpenGitHubRepoAsync(); + Assert.Equal(new Uri(vm.GitHubRepoUrl), launcher.LaunchedUri); + + await vm.OpenGitHubReleasesAsync(); + Assert.Equal(new Uri(vm.GitHubReleasesUrl), launcher.LaunchedUri); + + await vm.OpenApiDocsAsync(); + Assert.Equal(new Uri(vm.ApiDocsUrl), launcher.LaunchedUri); + } + + [Fact] + public void CreatorAttribution_ContainsExpectedAuthorAndRepositoryLinks() + { + var launcher = new DummyLauncher(); + var vm = new AboutViewModel(launcher); + + Assert.Equal("Designed & Developed by", vm.CreatorTitle); + Assert.Equal("Fatih Emre Sarman", vm.CreatorName); + Assert.Equal("@fatihemres", vm.CreatorGitHub); + Assert.Equal("https://github.com/fatihemres", vm.CreatorProfileUrl); + Assert.Contains("Fatih Emre Sarman", vm.CopyrightNotice); + Assert.Contains("2026", vm.CopyrightNotice); + } + + [Fact] + public async Task OpenCreatorProfileAsync_LaunchesCorrectProfileUri() + { + var launcher = new DummyLauncher(); + var vm = new AboutViewModel(launcher); + + await vm.OpenCreatorProfileAsync(); + + Assert.NotNull(launcher.LaunchedUri); + Assert.Equal(new Uri("https://github.com/fatihemres"), launcher.LaunchedUri); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/ApiInspectorViewModelTests.cs b/desktop/SteamAPI.Desktop.Tests/ApiInspectorViewModelTests.cs new file mode 100644 index 0000000..809d0be --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/ApiInspectorViewModelTests.cs @@ -0,0 +1,93 @@ +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; +using SteamAPI.Desktop.ViewModels; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class ApiInspectorViewModelTests +{ + private class FakeClipboard : IClipboardService + { + public string? CopiedText { get; private set; } + public Task SetTextAsync(string text) + { + CopiedText = text; + return Task.CompletedTask; + } + } + + [Fact] + public void AddRecord_InsertsNewestAtBeginningAndSelectsIt() + { + var clip = new FakeClipboard(); + var vm = new ApiInspectorViewModel(clip); + + var record1 = new ApiInspectionRecord { RequestUrl = "http://localhost/1" }; + var record2 = new ApiInspectionRecord { RequestUrl = "http://localhost/2" }; + + vm.AddRecord(record1); + vm.AddRecord(record2); + + Assert.Equal(2, vm.Records.Count); + Assert.Equal("http://localhost/2", vm.Records[0].RequestUrl); + Assert.Equal(record2, vm.SelectedRecord); + } + + [Fact] + public void AddRecord_CapsHistoryAtFiftyItems() + { + var clip = new FakeClipboard(); + var vm = new ApiInspectorViewModel(clip); + + for (int i = 0; i < 60; i++) + { + vm.AddRecord(new ApiInspectionRecord { RequestUrl = $"http://localhost/{i}" }); + } + + Assert.Equal(50, vm.Records.Count); + // The most recent record is 59 + Assert.Equal("http://localhost/59", vm.Records[0].RequestUrl); + // The oldest preserved is 10 (0..9 evicted) + Assert.Equal("http://localhost/10", vm.Records[49].RequestUrl); + } + + [Fact] + public void ClearHistory_EmptiesRecordsAndResetsSelection() + { + var clip = new FakeClipboard(); + var vm = new ApiInspectorViewModel(clip); + + vm.AddRecord(new ApiInspectionRecord { RequestUrl = "http://localhost/1" }); + Assert.Single(vm.Records); + + vm.ClearHistory(); + + Assert.Empty(vm.Records); + Assert.Null(vm.SelectedRecord); + } + + [Fact] + public async Task CopyCommands_CopyJsonAndUrlToClipboard() + { + var clip = new FakeClipboard(); + var vm = new ApiInspectorViewModel(clip); + + var record = new ApiInspectionRecord + { + RequestUrl = "http://localhost:5271/steam", + RequestBodyJson = "{\"game\":\"portal\"}", + ResponseBodyJson = "[{\"name\":\"Portal\"}]" + }; + vm.AddRecord(record); + + await vm.CopyRequestJsonAsync(); + Assert.Equal("{\"game\":\"portal\"}", clip.CopiedText); + + await vm.CopyResponseJsonAsync(); + Assert.Equal("[{\"name\":\"Portal\"}]", clip.CopiedText); + + await vm.CopyEndpointUrlAsync(); + Assert.Equal("http://localhost:5271/steam", clip.CopiedText); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/ApiProcessManagerTests.cs b/desktop/SteamAPI.Desktop.Tests/ApiProcessManagerTests.cs new file mode 100644 index 0000000..6b84bba --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/ApiProcessManagerTests.cs @@ -0,0 +1,301 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using SteamAPI.Desktop.Services; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +[CollectionDefinition("ApiProcessManager", DisableParallelization = true)] +public class ApiProcessManagerTestCollection +{ +} + +[Collection("ApiProcessManager")] +public class ApiProcessManagerTests : IDisposable +{ + public ApiProcessManagerTests() + { + WaitForPortToRelease(5271, 3000); + } + + public void Dispose() + { + WaitForPortToRelease(5271, 3000); + } + + private static void WaitForPortToRelease(int port, int timeoutMs) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + try + { + using var client = new TcpClient(); + var result = client.BeginConnect("127.0.0.1", port, null, null); + var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(150)); + if (success && client.Connected) + { + client.EndConnect(result); + Thread.Sleep(100); + continue; + } + return; + } + catch + { + return; + } + } + } + + [Fact] + public async Task StartAsync_WhenExecutableDoesNotExist_SetsStateToFailedAndStopsCleanly() + { + using var manager = new ApiProcessManager(); + var nonExistentPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.exe"); + + var started = await manager.StartAsync(nonExistentPath); + + Assert.False(started); + Assert.Equal(ApiProcessState.Failed, manager.State); + Assert.Null(manager.ProcessId); + Assert.NotNull(manager.LastError); + + await manager.StopAsync(); + Assert.Equal(ApiProcessState.Stopped, manager.State); + } + + [Fact] + public async Task StartAsync_WhenChildExitsImmediately_CleansUpAndSetsFailedState() + { + string? exitPath = null; + if (OperatingSystem.IsWindows()) + { + var hostnamePath = Path.Combine(Environment.SystemDirectory, "hostname.exe"); + if (File.Exists(hostnamePath)) + { + exitPath = hostnamePath; + } + } + else + { + if (File.Exists("/usr/bin/true")) exitPath = "/usr/bin/true"; + else if (File.Exists("/bin/true")) exitPath = "/bin/true"; + } + + if (exitPath == null) + { + return; + } + + using var manager = new ApiProcessManager(); + + var started = await manager.StartAsync(exitPath).WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.False(started); + Assert.Equal(ApiProcessState.Failed, manager.State); + Assert.Null(manager.ProcessId); + Assert.NotNull(manager.LastError); + } + + [Fact] + public async Task StartAsync_WhenPortOccupied_FailsGracefullyWithoutSendingHttpRequests() + { + TcpListener? dummyListener = null; + var bytesReceived = 0; + using var listenerCts = new CancellationTokenSource(); + + try + { + dummyListener = new TcpListener(IPAddress.Loopback, 5271); + dummyListener.Start(); + + // Background worker to accept connections on dummyListener and count any received bytes + _ = Task.Run(async () => + { + while (!listenerCts.IsCancellationRequested) + { + try + { + using var client = await dummyListener.AcceptTcpClientAsync(listenerCts.Token); + var stream = client.GetStream(); + var buffer = new byte[1024]; + int read; + while ((read = await stream.ReadAsync(buffer, listenerCts.Token)) > 0) + { + Interlocked.Add(ref bytesReceived, read); + } + } + catch + { + break; + } + } + }); + } + catch (SocketException) + { + // Port 5271 already occupied in the environment; proceed to test against it + } + + try + { + using var manager = new ApiProcessManager(); + var started = await manager.StartAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.False(started); + Assert.Equal(ApiProcessState.Failed, manager.State); + Assert.Null(manager.ProcessId); + Assert.NotNull(manager.LastError); + Assert.Contains("Port 5271 is already in use by another application", manager.LastError); + + // Crucial verification: zero HTTP payload/bytes were sent to the occupied listener + Assert.Equal(0, Volatile.Read(ref bytesReceived)); + + // Verify the external listener was not terminated or crashed + if (dummyListener != null) + { + using var probe = new TcpClient(); + await probe.ConnectAsync("127.0.0.1", 5271).WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(probe.Connected); + } + } + finally + { + listenerCts.Cancel(); + dummyListener?.Stop(); + } + } + + [Fact] + public async Task StartAsync_WhenAlreadyRunning_ReturnsTrueIdempotently() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var manager = new ApiProcessManager(); + var started1 = await manager.StartAsync().WaitAsync(TimeSpan.FromSeconds(12)); + Assert.True(started1, $"Expected SteamAPI to start, LastError: {manager.LastError}"); + Assert.Equal(ApiProcessState.Running, manager.State); + + var started2 = await manager.StartAsync().WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(started2); + Assert.Equal(ApiProcessState.Running, manager.State); + + await manager.StopAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(ApiProcessState.Stopped, manager.State); + } + + [Fact] + public async Task StartAsync_ConcurrentCalls_SafeStartupAndShutdown() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var manager = new ApiProcessManager(); + var t1 = manager.StartAsync(); + var t2 = manager.StartAsync(); + + var results = await Task.WhenAll(t1, t2).WaitAsync(TimeSpan.FromSeconds(15)); + // At least one returned true or both resolved to the running instance + Assert.True(results[0] || results[1]); + Assert.Equal(ApiProcessState.Running, manager.State); + Assert.NotNull(manager.ProcessId); + + await manager.StopAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(ApiProcessState.Stopped, manager.State); + } + + [Fact] + public async Task StopAsync_DuringRunningState_DoesNotDeadlockWithProcessExitedEvent() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Repeat 3 cycles against the real companion SteamAPI binary to stress-test StopAsync() vs Process.Exited + for (int i = 0; i < 3; i++) + { + using var manager = new ApiProcessManager(); + var started = await manager.StartAsync().WaitAsync(TimeSpan.FromSeconds(12)); + Assert.True(started, $"Expected SteamAPI to start on iteration {i}, LastError: {manager.LastError}"); + Assert.Equal(ApiProcessState.Running, manager.State); + + var pid = manager.ProcessId; + Assert.NotNull(pid); + + // StopAsync must complete without AB-BA deadlock and terminate the child process + await manager.StopAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(ApiProcessState.Stopped, manager.State); + Assert.Null(manager.ProcessId); + + // Verify child process is terminated + try + { + using var proc = Process.GetProcessById(pid.Value); + Assert.True(proc.WaitForExit(2000)); + } + catch (ArgumentException) + { + // Process already exited completely + } + } + } + + [Fact] + public async Task StopAsync_WhileStarting_CancelsStartupAndTerminatesChildImmediately() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var manager = new ApiProcessManager(); + var startTask = manager.StartAsync(); + + // Give Process.Start a brief moment to spawn the process before cancelling + await Task.Delay(150); + + await manager.StopAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var startResult = await startTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.False(startResult); + Assert.Equal(ApiProcessState.Stopped, manager.State); + Assert.Null(manager.ProcessId); + } + + [Fact] + public async Task Dispose_TerminatesOwnedProcessCleanly() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + int? pid; + using (var manager = new ApiProcessManager()) + { + var started = await manager.StartAsync().WaitAsync(TimeSpan.FromSeconds(12)); + Assert.True(started, $"Expected SteamAPI to start, LastError: {manager.LastError}"); + pid = manager.ProcessId; + Assert.NotNull(pid); + } + + // After Dispose, process must be terminated + try + { + using var proc = Process.GetProcessById(pid.Value); + Assert.True(proc.WaitForExit(3000)); + } + catch (ArgumentException) + { + // Already exited + } + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/AssemblyMetadataTests.cs b/desktop/SteamAPI.Desktop.Tests/AssemblyMetadataTests.cs new file mode 100644 index 0000000..b7e70dc --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/AssemblyMetadataTests.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using SteamAPI.Desktop.ViewModels; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class AssemblyMetadataTests +{ + [Fact] + public void DesktopAssembly_ContainsExpectedAuthorAndCopyrightMetadata() + { + var assembly = typeof(MainWindowViewModel).Assembly; + + var companyAttr = assembly.GetCustomAttribute(); + Assert.NotNull(companyAttr); + Assert.Equal("Fatih Emre Sarman", companyAttr.Company); + + var copyrightAttr = assembly.GetCustomAttribute(); + Assert.NotNull(copyrightAttr); + Assert.Contains("Copyright © 2026 Fatih Emre Sarman", copyrightAttr.Copyright); + + var productAttr = assembly.GetCustomAttribute(); + Assert.NotNull(productAttr); + Assert.Equal("SteamAPI Desktop", productAttr.Product); + + var descriptionAttr = assembly.GetCustomAttribute(); + Assert.NotNull(descriptionAttr); + Assert.Contains("SteamAPI", descriptionAttr.Description); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/DemoDataServiceTests.cs b/desktop/SteamAPI.Desktop.Tests/DemoDataServiceTests.cs new file mode 100644 index 0000000..bc897f4 --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/DemoDataServiceTests.cs @@ -0,0 +1,49 @@ +using SteamAPI.Desktop.Services; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class DemoDataServiceTests +{ + private readonly DemoDataService _demoService = new(); + + [Fact] + public async Task SearchSyntheticGamesAsync_WithMatchingQuery_ReturnsMatchingResults() + { + var response = await _demoService.SearchSyntheticGamesAsync("portal", culture: 2); + + Assert.True(response.IsSuccess); + Assert.Equal(200, response.StatusCode); + Assert.NotNull(response.Data); + Assert.NotEmpty(response.Data); + Assert.Contains(response.Data, g => g.Name.Contains("Portal", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task SearchSyntheticGamesAsync_AdjustsPricesByCulture() + { + var usResponse = await _demoService.SearchSyntheticGamesAsync("Half-Life", culture: 2); + var trResponse = await _demoService.SearchSyntheticGamesAsync("Half-Life", culture: 1); + + Assert.True(usResponse.IsSuccess); + Assert.True(trResponse.IsSuccess); + Assert.NotNull(usResponse.Data); + Assert.NotNull(trResponse.Data); + + var usGame = usResponse.Data.First(g => g.Price.HasValue); + var trGame = trResponse.Data.First(g => g.Price.HasValue); + + // Turkey regional pricing is discounted in synthetic fixture + Assert.True(trGame.Price < usGame.Price); + } + + [Fact] + public async Task SearchSyntheticGamesAsync_HandlesFreeToPlayAndPaidListings() + { + var response = await _demoService.SearchSyntheticGamesAsync("Counter-Strike", culture: 2); + + Assert.True(response.IsSuccess); + Assert.NotNull(response.Data); + Assert.Contains(response.Data, g => g.IsFree && g.SaleType == 1); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/GameExplorerViewModelTests.cs b/desktop/SteamAPI.Desktop.Tests/GameExplorerViewModelTests.cs new file mode 100644 index 0000000..c2127ba --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/GameExplorerViewModelTests.cs @@ -0,0 +1,236 @@ +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; +using SteamAPI.Desktop.ViewModels; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class GameExplorerViewModelTests +{ +#pragma warning disable CS0067 + private class FakeApiClient : ISteamApiClient + { + public string BaseUrl { get; set; } = "http://localhost:5271"; + public int TimeoutSeconds { get; set; } = 30; + public event Action? OnInspectionRecordCreated; + + public bool WasCalled { get; private set; } + public string? LastQuery { get; private set; } + public int? LastCulture { get; private set; } + + public Task>> SearchGamesAsync(string game, int culture, CancellationToken cancellationToken = default) + { + WasCalled = true; + LastQuery = game; + LastCulture = culture; + + IReadOnlyList list = new List + { + new() { Name = "Portal", AppId = "400", SaleType = 0, Price = 9.99, StoreLink = "https://store.steampowered.com/app/400" } + }; + + return Task.FromResult(ApiResponse>.Success(list, 200, 15)); + } + + public Task> CheckConnectionAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(ApiResponse.Success(true, 200, 10)); + } + } + + private class FakeDemoDataService : IDemoDataService + { + public bool WasCalled { get; private set; } + + public Task>> SearchSyntheticGamesAsync(string query, int culture, CancellationToken cancellationToken = default) + { + WasCalled = true; + IReadOnlyList list = new List + { + new() { Name = "Demo Game", AppId = "123", SaleType = 1, Price = null, StoreLink = "https://store.steampowered.com/app/123" } + }; + return Task.FromResult(ApiResponse>.Success(list, 200, 5)); + } + } +#pragma warning restore CS0067 + + private class FakeSettingsService : ISettingsService + { + public AppSettings CurrentSettings { get; set; } = new(); + public void SaveSettings(AppSettings settings) => CurrentSettings = settings; + public void ResetToDefaults() => CurrentSettings = new(); + } + + private class FakeClipboardService : IClipboardService + { + public string? LastCopiedText { get; private set; } + public Task SetTextAsync(string text) + { + LastCopiedText = text; + return Task.CompletedTask; + } + } + + private class FakeLauncherService : ILauncherService + { + public Uri? LastLaunchedUri { get; private set; } + public Task LaunchUriAsync(Uri uri) + { + LastLaunchedUri = uri; + return Task.FromResult(true); + } + } + + [Fact] + public async Task SearchAsync_WithEmptyQuery_SetsValidationErrorAndDoesNotCallApi() + { + var api = new FakeApiClient(); + var demo = new FakeDemoDataService(); + var settings = new FakeSettingsService(); + var clip = new FakeClipboardService(); + var launch = new FakeLauncherService(); + + var vm = new GameExplorerViewModel(api, demo, settings, clip, launch) + { + SearchQuery = " " + }; + + await vm.SearchAsync(); + + Assert.NotNull(vm.ErrorMessage); + Assert.False(api.WasCalled); + Assert.False(demo.WasCalled); + } + + [Fact] + public async Task SearchAsync_WithQueryOver200Chars_SetsValidationError() + { + var api = new FakeApiClient(); + var demo = new FakeDemoDataService(); + var settings = new FakeSettingsService(); + var clip = new FakeClipboardService(); + var launch = new FakeLauncherService(); + + var vm = new GameExplorerViewModel(api, demo, settings, clip, launch) + { + SearchQuery = new string('A', 201) + }; + + await vm.SearchAsync(); + + Assert.NotNull(vm.ErrorMessage); + Assert.Contains("200", vm.ErrorMessage); + Assert.False(api.WasCalled); + } + + [Fact] + public async Task SearchAsync_InLiveMode_CallsApiClientAndPopulatesResults() + { + var api = new FakeApiClient(); + var demo = new FakeDemoDataService(); + var settings = new FakeSettingsService { CurrentSettings = new AppSettings { DemoMode = false } }; + var clip = new FakeClipboardService(); + var launch = new FakeLauncherService(); + + var vm = new GameExplorerViewModel(api, demo, settings, clip, launch) + { + SearchQuery = "Portal" + }; + + await vm.SearchAsync(); + + Assert.True(api.WasCalled); + Assert.False(demo.WasCalled); + Assert.True(vm.HasSearched); + Assert.Single(vm.Listings); + Assert.Equal("Portal", vm.Listings[0].Name); + Assert.NotNull(vm.SelectedListing); + Assert.Equal("Portal", vm.SelectedListing.Name); + } + + [Fact] + public async Task SearchAsync_InDemoMode_CallsDemoService() + { + var api = new FakeApiClient(); + var demo = new FakeDemoDataService(); + var settings = new FakeSettingsService { CurrentSettings = new AppSettings { DemoMode = true } }; + var clip = new FakeClipboardService(); + var launch = new FakeLauncherService(); + + var vm = new GameExplorerViewModel(api, demo, settings, clip, launch); + vm.UpdateDemoMode(true); + vm.SearchQuery = "Demo"; + + await vm.SearchAsync(); + + Assert.False(api.WasCalled); + Assert.True(demo.WasCalled); + Assert.Single(vm.Listings); + Assert.Equal("Demo Game", vm.Listings[0].Name); + } + + [Fact] + public void ClearSearch_ResetsAllSearchFields() + { + var vm = new GameExplorerViewModel( + new FakeApiClient(), + new FakeDemoDataService(), + new FakeSettingsService(), + new FakeClipboardService(), + new FakeLauncherService()) + { + SearchQuery = "Half-Life", + ErrorMessage = "Some error", + StatusMessage = "Found 2 games", + HasSearched = true + }; + + vm.ClearSearch(); + + Assert.Empty(vm.SearchQuery); + Assert.Null(vm.ErrorMessage); + Assert.Null(vm.StatusMessage); + Assert.False(vm.HasSearched); + Assert.Empty(vm.Listings); + Assert.Null(vm.SelectedListing); + } + + [Fact] + public async Task CopyAppIdAsync_CopiesSelectedAppIdToClipboard() + { + var clip = new FakeClipboardService(); + var vm = new GameExplorerViewModel( + new FakeApiClient(), + new FakeDemoDataService(), + new FakeSettingsService(), + clip, + new FakeLauncherService()); + + var listing = new SteamGameListing { AppId = "730", Name = "CS2" }; + vm.SelectListing(listing); + + await vm.CopyAppIdAsync(listing); + + Assert.Equal("730", clip.LastCopiedText); + Assert.NotNull(vm.CopiedFeedbackMessage); + } + + [Fact] + public async Task OpenInSteamAsync_LaunchesSteamProtocolUri() + { + var launch = new FakeLauncherService(); + var vm = new GameExplorerViewModel( + new FakeApiClient(), + new FakeDemoDataService(), + new FakeSettingsService(), + new FakeClipboardService(), + launch); + + var listing = new SteamGameListing { AppId = "570", Name = "Dota 2" }; + + await vm.OpenInSteamAsync(listing); + + Assert.NotNull(launch.LastLaunchedUri); + Assert.Equal("steam://store/570", launch.LastLaunchedUri.ToString()); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/MainWindowViewModelTests.cs b/desktop/SteamAPI.Desktop.Tests/MainWindowViewModelTests.cs new file mode 100644 index 0000000..d187d94 --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/MainWindowViewModelTests.cs @@ -0,0 +1,191 @@ +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; +using SteamAPI.Desktop.ViewModels; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class MainWindowViewModelTests +{ + private class DummySettingsService : ISettingsService + { + public AppSettings CurrentSettings { get; set; } = new(); + public void SaveSettings(AppSettings settings) => CurrentSettings = settings; + public void ResetToDefaults() => CurrentSettings = new(); + } + + private class DummyProcessManager : IApiProcessManager + { + public ApiProcessState State { get; set; } = ApiProcessState.Stopped; + public string? LastError { get; set; } + public int? ProcessId => null; + public string BoundUrl => "http://127.0.0.1:5271"; + public event Action? OnStateChanged; + public bool StoppedCalled { get; private set; } + + public Task StartAsync(string? executablePath = null, CancellationToken cancellationToken = default) + { + State = ApiProcessState.Running; + OnStateChanged?.Invoke(State); + return Task.FromResult(true); + } + + public Task StopAsync() + { + StoppedCalled = true; + State = ApiProcessState.Stopped; + OnStateChanged?.Invoke(State); + return Task.CompletedTask; + } + + public void Dispose() { } + } + + private class DummyClipboard : IClipboardService + { + public Task SetTextAsync(string text) => Task.CompletedTask; + } + + private class DummyLauncher : ILauncherService + { + public Task LaunchUriAsync(Uri uri) => Task.FromResult(true); + } + + [Fact] + public void SelectTab_SwitchesActivePageCorrectly() + { + var settingsService = new DummySettingsService(); + var processManager = new DummyProcessManager(); + var client = new SteamApiClient(); + var demo = new DemoDataService(); + var clip = new DummyClipboard(); + var launcher = new DummyLauncher(); + + var explorer = new GameExplorerViewModel(client, demo, settingsService, clip, launcher); + var inspector = new ApiInspectorViewModel(clip, client); + var settings = new SettingsViewModel(settingsService, client, processManager); + var about = new AboutViewModel(launcher); + + var mainVm = new MainWindowViewModel(explorer, inspector, settings, about, settingsService, processManager); + + Assert.Equal(0, mainVm.SelectedTabIndex); + Assert.Equal(explorer, mainVm.CurrentView); + Assert.True(mainVm.IsExplorerActive); + Assert.False(mainVm.IsInspectorActive); + + mainVm.SelectTab(1); + Assert.Equal(1, mainVm.SelectedTabIndex); + Assert.Equal(inspector, mainVm.CurrentView); + Assert.True(mainVm.IsInspectorActive); + Assert.False(mainVm.IsExplorerActive); + + mainVm.SelectTab(2); + Assert.Equal(2, mainVm.SelectedTabIndex); + Assert.Equal(settings, mainVm.CurrentView); + Assert.True(mainVm.IsSettingsActive); + + mainVm.SelectTab(3); + Assert.Equal(3, mainVm.SelectedTabIndex); + Assert.Equal(about, mainVm.CurrentView); + Assert.True(mainVm.IsAboutActive); + } + + [Theory] + [InlineData("0", 0)] + [InlineData("1", 1)] + [InlineData("2", 2)] + [InlineData("3", 3)] + [InlineData(0, 0)] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 3)] + public void SelectTabCommand_AcceptsStringAndIntegerParameters(object parameter, int expectedIndex) + { + var settingsService = new DummySettingsService(); + var processManager = new DummyProcessManager(); + var client = new SteamApiClient(); + var demo = new DemoDataService(); + var clip = new DummyClipboard(); + var launcher = new DummyLauncher(); + + var explorer = new GameExplorerViewModel(client, demo, settingsService, clip, launcher); + var inspector = new ApiInspectorViewModel(clip, client); + var settings = new SettingsViewModel(settingsService, client, processManager); + var about = new AboutViewModel(launcher); + + var mainVm = new MainWindowViewModel(explorer, inspector, settings, about, settingsService, processManager); + + // Execute via RelayCommand + mainVm.SelectTabCommand.Execute(parameter); + + Assert.Equal(expectedIndex, mainVm.SelectedTabIndex); + Assert.Equal(expectedIndex == 0, mainVm.IsExplorerActive); + Assert.Equal(expectedIndex == 1, mainVm.IsInspectorActive); + Assert.Equal(expectedIndex == 2, mainVm.IsSettingsActive); + Assert.Equal(expectedIndex == 3, mainVm.IsAboutActive); + } + + [Theory] + [InlineData("invalid")] + [InlineData("-1")] + [InlineData("4")] + [InlineData(null)] + [InlineData(-1)] + [InlineData(99)] + public void SelectTabCommand_IgnoresInvalidParameters(object? invalidParameter) + { + var settingsService = new DummySettingsService(); + var processManager = new DummyProcessManager(); + var client = new SteamApiClient(); + var demo = new DemoDataService(); + var clip = new DummyClipboard(); + var launcher = new DummyLauncher(); + + var explorer = new GameExplorerViewModel(client, demo, settingsService, clip, launcher); + var inspector = new ApiInspectorViewModel(clip, client); + var settings = new SettingsViewModel(settingsService, client, processManager); + var about = new AboutViewModel(launcher); + + var mainVm = new MainWindowViewModel(explorer, inspector, settings, about, settingsService, processManager); + + // Move to tab 2 first + mainVm.SelectTabCommand.Execute("2"); + Assert.Equal(2, mainVm.SelectedTabIndex); + + // Execute with invalid parameter - should maintain tab 2 + mainVm.SelectTabCommand.Execute(invalidParameter); + Assert.Equal(2, mainVm.SelectedTabIndex); + Assert.Equal(settings, mainVm.CurrentView); + Assert.True(mainVm.IsSettingsActive); + } + + [Theory] + [InlineData(ApiProcessState.Running)] + [InlineData(ApiProcessState.Starting)] + [InlineData(ApiProcessState.Failed)] + [InlineData(ApiProcessState.Stopped)] + public async Task ShutdownAsync_StopsApiProcessAcrossAllStates(ApiProcessState initialState) + { + var settingsService = new DummySettingsService(); + var processManager = new DummyProcessManager { State = initialState }; + using var client = new SteamApiClient(); + var demo = new DemoDataService(); + var clip = new DummyClipboard(); + var launcher = new DummyLauncher(); + + var explorer = new GameExplorerViewModel(client, demo, settingsService, clip, launcher); + var mainVm = new MainWindowViewModel( + explorer, + new ApiInspectorViewModel(clip, client), + new SettingsViewModel(settingsService, client, processManager), + new AboutViewModel(launcher), + settingsService, + processManager); + + explorer.CancelPendingOperations(); + await mainVm.ShutdownAsync(); + + Assert.True(processManager.StoppedCalled); + Assert.Equal(ApiProcessState.Stopped, processManager.State); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/SettingsServiceTests.cs b/desktop/SteamAPI.Desktop.Tests/SettingsServiceTests.cs new file mode 100644 index 0000000..cbe8aad --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/SettingsServiceTests.cs @@ -0,0 +1,71 @@ +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class SettingsServiceTests : IDisposable +{ + private readonly string _tempFile; + + public SettingsServiceTests() + { + _tempFile = Path.Combine(Path.GetTempPath(), $"steamapi_test_settings_{Guid.NewGuid()}.json"); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + try { File.Delete(_tempFile); } catch { } + } + } + + [Fact] + public void LoadSettings_WhenFileDoesNotExist_ReturnsDefaults() + { + var service = new SettingsService(_tempFile); + + Assert.NotNull(service.CurrentSettings); + Assert.Equal("http://localhost:5271", service.CurrentSettings.ApiBaseUrl); + Assert.Equal(30, service.CurrentSettings.TimeoutSeconds); + Assert.False(service.CurrentSettings.DemoMode); + } + + [Fact] + public void SaveSettings_PersistsDataToDiskAndReloadsCorrectly() + { + var service = new SettingsService(_tempFile); + + var newSettings = new AppSettings + { + ApiBaseUrl = "http://127.0.0.1:8888", + TimeoutSeconds = 42, + DemoMode = true, + AutoStartApi = true + }; + + service.SaveSettings(newSettings); + + // Read anew from disk + var reloadedService = new SettingsService(_tempFile); + Assert.Equal("http://127.0.0.1:8888", reloadedService.CurrentSettings.ApiBaseUrl); + Assert.Equal(42, reloadedService.CurrentSettings.TimeoutSeconds); + Assert.True(reloadedService.CurrentSettings.DemoMode); + Assert.True(reloadedService.CurrentSettings.AutoStartApi); + } + + [Fact] + public void ResetToDefaults_OverwritesExistingSettingsWithDefaults() + { + var service = new SettingsService(_tempFile); + service.SaveSettings(new AppSettings { DemoMode = true, TimeoutSeconds = 99 }); + + service.ResetToDefaults(); + + var reloaded = new SettingsService(_tempFile); + Assert.False(reloaded.CurrentSettings.DemoMode); + Assert.Equal(30, reloaded.CurrentSettings.TimeoutSeconds); + Assert.Equal("http://localhost:5271", reloaded.CurrentSettings.ApiBaseUrl); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/SettingsViewModelTests.cs b/desktop/SteamAPI.Desktop.Tests/SettingsViewModelTests.cs new file mode 100644 index 0000000..3f76ecc --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/SettingsViewModelTests.cs @@ -0,0 +1,149 @@ +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; +using SteamAPI.Desktop.ViewModels; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class SettingsViewModelTests +{ + private class FakeSettingsService : ISettingsService + { + public AppSettings CurrentSettings { get; set; } = new(); + public void SaveSettings(AppSettings settings) => CurrentSettings = settings; + public void ResetToDefaults() => CurrentSettings = new(); + } + +#pragma warning disable CS0067 + private class FakeApiClient : ISteamApiClient + { + public string BaseUrl { get; set; } = "http://localhost:5271"; + public int TimeoutSeconds { get; set; } = 30; + public event Action? OnInspectionRecordCreated; + public bool ConnectionSuccess { get; set; } = true; + + public Task>> SearchGamesAsync(string game, int culture, CancellationToken cancellationToken = default) + { + return Task.FromResult(ApiResponse>.Success(new List(), 200, 10)); + } + + public Task> CheckConnectionAsync(CancellationToken cancellationToken = default) + { + return ConnectionSuccess + ? Task.FromResult(ApiResponse.Success(true, 200, 12)) + : Task.FromResult(ApiResponse.Failure(502, "Failed to connect", null, 12)); + } + } +#pragma warning restore CS0067 + + private class FakeProcessManager : IApiProcessManager + { + public ApiProcessState State { get; set; } = ApiProcessState.Stopped; + public string? LastError { get; set; } + public int? ProcessId => State == ApiProcessState.Running ? 1234 : null; + public string BoundUrl => "http://127.0.0.1:5271"; + public event Action? OnStateChanged; + + public Task StartAsync(string? executablePath = null, CancellationToken cancellationToken = default) + { + State = ApiProcessState.Running; + OnStateChanged?.Invoke(State); + return Task.FromResult(true); + } + + public Task StopAsync() + { + State = ApiProcessState.Stopped; + OnStateChanged?.Invoke(State); + return Task.CompletedTask; + } + + public void Dispose() { } + } + + [Fact] + public void LoadSettings_InitializesPropertiesFromService() + { + var settingsService = new FakeSettingsService + { + CurrentSettings = new AppSettings + { + ApiBaseUrl = "http://127.0.0.1:9000", + TimeoutSeconds = 45, + DemoMode = true, + AutoStartApi = true + } + }; + + var vm = new SettingsViewModel(settingsService, new FakeApiClient(), new FakeProcessManager()); + + Assert.Equal("http://127.0.0.1:9000", vm.ApiBaseUrl); + Assert.Equal(45, vm.TimeoutSeconds); + Assert.True(vm.IsDemoMode); + Assert.True(vm.AutoStartApi); + } + + [Fact] + public void SaveSettings_PersistsChangesAndFiresDemoToggledEvent() + { + var settingsService = new FakeSettingsService(); + var client = new FakeApiClient(); + var vm = new SettingsViewModel(settingsService, client, new FakeProcessManager()); + + bool? demoToggledValue = null; + vm.OnDemoModeToggled += isDemo => demoToggledValue = isDemo; + + vm.ApiBaseUrl = "http://localhost:7030"; + vm.TimeoutSeconds = 60; + vm.IsDemoMode = true; + vm.AutoStartApi = false; + + vm.SaveSettings(); + + Assert.Equal("http://localhost:7030", settingsService.CurrentSettings.ApiBaseUrl); + Assert.Equal(60, settingsService.CurrentSettings.TimeoutSeconds); + Assert.True(settingsService.CurrentSettings.DemoMode); + Assert.Equal("http://localhost:7030", client.BaseUrl); + Assert.True(demoToggledValue); + Assert.NotNull(vm.SaveFeedbackMessage); + } + + [Fact] + public void ResetDefaults_RestoresInitialState() + { + var settingsService = new FakeSettingsService + { + CurrentSettings = new AppSettings { DemoMode = true, ApiBaseUrl = "http://custom:1234" } + }; + + var vm = new SettingsViewModel(settingsService, new FakeApiClient(), new FakeProcessManager()); + vm.ResetDefaults(); + + Assert.Equal("http://localhost:5271", vm.ApiBaseUrl); + Assert.False(vm.IsDemoMode); + } + + [Fact] + public async Task TestConnectionAsync_SetsSuccessStateWhenEndpointResponds() + { + var client = new FakeApiClient { ConnectionSuccess = true }; + var vm = new SettingsViewModel(new FakeSettingsService(), client, new FakeProcessManager()); + + await vm.TestConnectionAsync(); + + Assert.True(vm.IsConnectionSuccessful); + Assert.Contains("successful", vm.ConnectionStatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TestConnectionAsync_SetsFailureStateWhenEndpointUnreachable() + { + var client = new FakeApiClient { ConnectionSuccess = false }; + var vm = new SettingsViewModel(new FakeSettingsService(), client, new FakeProcessManager()); + + await vm.TestConnectionAsync(); + + Assert.False(vm.IsConnectionSuccessful); + Assert.Contains("failed", vm.ConnectionStatusMessage, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/SteamAPI.Desktop.Tests.csproj b/desktop/SteamAPI.Desktop.Tests/SteamAPI.Desktop.Tests.csproj new file mode 100644 index 0000000..49afaf0 --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/SteamAPI.Desktop.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/desktop/SteamAPI.Desktop.Tests/SteamApiClientTests.cs b/desktop/SteamAPI.Desktop.Tests/SteamApiClientTests.cs new file mode 100644 index 0000000..759b3b5 --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/SteamApiClientTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Text; +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class SteamApiClientTests +{ + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public MockHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_handler(request)); + } + } + + [Fact] + public async Task SearchGamesAsync_On200OK_ReturnsListingsAndFiresTelemetryEvent() + { + const string responseBody = """ + [ + { + "name": "Left 4 Dead 2", + "storeLink": "https://store.steampowered.com/app/550/Left_4_Dead_2/", + "imageLink": "https://cdn.akamai.steamstatic.com/steam/apps/550/capsule_sm_120.jpg", + "price": 9.99, + "saleType": 0, + "appId": "550" + } + ] + """; + + var handler = new MockHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + + var client = new SteamApiClient("http://localhost:5271", 30, new HttpClient(handler)); + ApiInspectionRecord? recordedTelemetry = null; + client.OnInspectionRecordCreated += record => recordedTelemetry = record; + + var result = await client.SearchGamesAsync("Left 4 Dead", 2); + + Assert.True(result.IsSuccess); + Assert.Equal(200, result.StatusCode); + Assert.NotNull(result.Data); + Assert.Single(result.Data); + Assert.Equal("Left 4 Dead 2", result.Data[0].Name); + Assert.Equal("550", result.Data[0].AppId); + + // Verify telemetry + Assert.NotNull(recordedTelemetry); + Assert.Equal(200, recordedTelemetry.ResponseStatusCode); + Assert.Equal("POST", recordedTelemetry.HttpMethod); + Assert.Contains("Left 4 Dead", recordedTelemetry.RequestBodyJson); + } + + [Fact] + public async Task SearchGamesAsync_On400BadRequest_ParsesValidationErrors() + { + const string problemJson = """ + { + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Game": [ + "The Game field is required." + ] + } + } + """; + + var handler = new MockHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(problemJson, Encoding.UTF8, "application/problem+json") + }); + + var client = new SteamApiClient("http://localhost:5271", 30, new HttpClient(handler)); + var result = await client.SearchGamesAsync("", 2); + + Assert.False(result.IsSuccess); + Assert.Equal(400, result.StatusCode); + Assert.Contains("validation", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SearchGamesAsync_On502BadGateway_HandlesUpstreamError() + { + var handler = new MockHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("Upstream Steam store unreachable", Encoding.UTF8, "text/plain") + }); + + var client = new SteamApiClient("http://localhost:5271", 30, new HttpClient(handler)); + var result = await client.SearchGamesAsync("portal", 2); + + Assert.False(result.IsSuccess); + Assert.Equal(502, result.StatusCode); + Assert.NotNull(result.ErrorMessage); + } + + [Fact] + public async Task CheckConnectionAsync_On200OK_ReportsSuccess() + { + var handler = new MockHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", Encoding.UTF8, "application/json") + }); + + var client = new SteamApiClient("http://localhost:5271", 30, new HttpClient(handler)); + var result = await client.CheckConnectionAsync(); + + Assert.True(result.IsSuccess); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/SteamCultureTests.cs b/desktop/SteamAPI.Desktop.Tests/SteamCultureTests.cs new file mode 100644 index 0000000..a493efc --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/SteamCultureTests.cs @@ -0,0 +1,30 @@ +using SteamAPI.Desktop.Models; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class SteamCultureTests +{ + [Fact] + public void SupportedCultures_ContainsExpectedThreeRegionalCultures() + { + var cultures = SteamCulture.SupportedCultures; + + Assert.Equal(3, cultures.Count); + + var tr = cultures.FirstOrDefault(c => c.Id == 1); + Assert.NotNull(tr); + Assert.Equal("TR", tr.CountryCode); + Assert.Equal("turkish", tr.LanguageName); + + var us = cultures.FirstOrDefault(c => c.Id == 2); + Assert.NotNull(us); + Assert.Equal("US", us.CountryCode); + Assert.Equal("english", us.LanguageName); + + var es = cultures.FirstOrDefault(c => c.Id == 3); + Assert.NotNull(es); + Assert.Equal("ES", es.CountryCode); + Assert.Equal("spanish", es.LanguageName); + } +} diff --git a/desktop/SteamAPI.Desktop.Tests/SteamGameListingTests.cs b/desktop/SteamAPI.Desktop.Tests/SteamGameListingTests.cs new file mode 100644 index 0000000..87e5493 --- /dev/null +++ b/desktop/SteamAPI.Desktop.Tests/SteamGameListingTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using SteamAPI.Desktop.Models; +using Xunit; + +namespace SteamAPI.Desktop.Tests; + +public class SteamGameListingTests +{ + [Fact] + public void Deserialization_MatchesVerifiedSixFieldContract() + { + // Arrange + const string json = """ + { + "name": "Portal 2", + "storeLink": "https://store.steampowered.com/app/620/Portal_2/", + "imageLink": "https://cdn.akamai.steamstatic.com/steam/apps/620/capsule_sm_120.jpg", + "price": 9.99, + "saleType": 0, + "appId": "620" + } + """; + + // Act + var listing = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + // Assert + Assert.NotNull(listing); + Assert.Equal("Portal 2", listing.Name); + Assert.Equal("https://store.steampowered.com/app/620/Portal_2/", listing.StoreLink); + Assert.Equal("https://cdn.akamai.steamstatic.com/steam/apps/620/capsule_sm_120.jpg", listing.ImageLink); + Assert.Equal(9.99, listing.Price); + Assert.Equal(0, listing.SaleType); + Assert.Equal("620", listing.AppId); + Assert.True(listing.IsPaid); + Assert.False(listing.IsFree); + Assert.False(listing.IsUnavailable); + Assert.Equal("9.99", listing.DisplayPrice); + Assert.Equal("PAID", listing.SaleTypeBadgeText); + Assert.Equal("steam://store/620", listing.SteamProtocolUrl); + } + + [Theory] + [InlineData(1, null, "Free to Play", true, false, false, "FREE TO PLAY")] + [InlineData(2, null, "Not Available", false, false, true, "NOT AVAILABLE")] + [InlineData(0, 14.50, "14.50", false, true, false, "PAID")] + [InlineData(0, null, "Paid", false, true, false, "PAID")] + public void DisplayPriceAndBadges_FormatCorrectlyAccordingToSaleType( + int saleType, + double? price, + string expectedDisplayPrice, + bool expectedIsFree, + bool expectedIsPaid, + bool expectedIsUnavailable, + string expectedBadge) + { + // Arrange + var listing = new SteamGameListing + { + Name = "Test Game", + SaleType = saleType, + Price = price, + AppId = "100" + }; + + // Act & Assert + Assert.Equal(expectedDisplayPrice, listing.DisplayPrice); + Assert.Equal(expectedIsFree, listing.IsFree); + Assert.Equal(expectedIsPaid, listing.IsPaid); + Assert.Equal(expectedIsUnavailable, listing.IsUnavailable); + Assert.Equal(expectedBadge, listing.SaleTypeBadgeText); + } + + [Fact] + public void SteamProtocolUrl_FallsBackToStoreLink_WhenAppIdMissing() + { + // Arrange + var listing = new SteamGameListing + { + Name = "Web Game", + AppId = "", + StoreLink = "https://store.steampowered.com/app/999/" + }; + + // Act & Assert + Assert.Equal("https://store.steampowered.com/app/999/", listing.SteamProtocolUrl); + } +} diff --git a/desktop/SteamAPI.Desktop/App.axaml b/desktop/SteamAPI.Desktop/App.axaml new file mode 100644 index 0000000..3259d2c --- /dev/null +++ b/desktop/SteamAPI.Desktop/App.axaml @@ -0,0 +1,80 @@ + + + + + + + + + #0E141B + #171D25 + #1B2838 + #2A475E + #2A3F5A + #66C0F4 + #1999E3 + #E5EFF7 + #8F98A0 + #A4D007 + #4C6B22 + #2ECC71 + #F39C12 + #E74C3C + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/desktop/SteamAPI.Desktop/App.axaml.cs b/desktop/SteamAPI.Desktop/App.axaml.cs new file mode 100644 index 0000000..8e10686 --- /dev/null +++ b/desktop/SteamAPI.Desktop/App.axaml.cs @@ -0,0 +1,64 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using SteamAPI.Desktop.Services; +using SteamAPI.Desktop.ViewModels; +using SteamAPI.Desktop.Views; + +namespace SteamAPI.Desktop; + +public partial class App : Application +{ + private MainWindowViewModel? _mainViewModel; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var settingsService = new SettingsService(); + var processManager = new ApiProcessManager(); + var apiClient = new SteamApiClient(settingsService.CurrentSettings.ApiBaseUrl, settingsService.CurrentSettings.TimeoutSeconds); + var demoDataService = new DemoDataService(); + var clipboardService = new DesktopClipboardService(); + var launcherService = new DesktopLauncherService(); + + var gameExplorer = new GameExplorerViewModel(apiClient, demoDataService, settingsService, clipboardService, launcherService); + var apiInspector = new ApiInspectorViewModel(clipboardService, apiClient); + var settings = new SettingsViewModel(settingsService, apiClient, processManager); + var about = new AboutViewModel(launcherService); + + _mainViewModel = new MainWindowViewModel(gameExplorer, apiInspector, settings, about, settingsService, processManager); + + var mainWindow = new MainWindow + { + DataContext = _mainViewModel + }; + + mainWindow.Closing += (_, _) => + { + _mainViewModel?.ShutdownAsync().GetAwaiter().GetResult(); + }; + + desktop.Exit += (_, _) => + { + _mainViewModel?.ShutdownAsync().GetAwaiter().GetResult(); + apiClient.Dispose(); + }; + + desktop.MainWindow = mainWindow; + + // Auto-start loopback API if configured + if (settingsService.CurrentSettings.AutoStartApi && !settingsService.CurrentSettings.DemoMode) + { + _ = processManager.StartAsync(); + } + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/desktop/SteamAPI.Desktop/Assets/avalonia-logo.ico b/desktop/SteamAPI.Desktop/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/desktop/SteamAPI.Desktop/Assets/avalonia-logo.ico differ diff --git a/desktop/SteamAPI.Desktop/Models/ApiInspectionRecord.cs b/desktop/SteamAPI.Desktop/Models/ApiInspectionRecord.cs new file mode 100644 index 0000000..d3ed5d7 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Models/ApiInspectionRecord.cs @@ -0,0 +1,33 @@ +namespace SteamAPI.Desktop.Models; + +/// +/// Detailed inspection record capturing HTTP telemetry for the API Inspector panel. +/// +public class ApiInspectionRecord +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.Now; + public string RequestUrl { get; init; } = string.Empty; + public string HttpMethod { get; init; } = "POST"; + public string RequestBodyJson { get; init; } = string.Empty; + public int ResponseStatusCode { get; init; } + public string ResponseStatusText { get; init; } = string.Empty; + public long DurationMs { get; init; } + public string ResponseBodyJson { get; init; } = string.Empty; + public bool IsSuccess => ResponseStatusCode >= 200 && ResponseStatusCode < 300; + public string? ErrorMessage { get; init; } + public int? ItemCount { get; init; } + public bool IsDemoMode { get; init; } + + public string StatusBadgeColor => ResponseStatusCode switch + { + 200 => "#238636", // Success Green + 400 => "#D29922", // Validation Yellow + 502 => "#F85149", // Upstream Error Red + 504 => "#DA3633", // Gateway Timeout Red + _ => "#8B949E" // Muted Gray / Connection Failed + }; + + public string DurationFormatted => $"{DurationMs} ms"; + public string TimestampFormatted => Timestamp.ToString("HH:mm:ss.fff"); +} diff --git a/desktop/SteamAPI.Desktop/Models/ApiResponse.cs b/desktop/SteamAPI.Desktop/Models/ApiResponse.cs new file mode 100644 index 0000000..0e62cd1 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Models/ApiResponse.cs @@ -0,0 +1,48 @@ +namespace SteamAPI.Desktop.Models; + +/// +/// Structured client response encapsulating data, HTTP status, and RFC 9457 ProblemDetails. +/// +/// Payload data type. +public class ApiResponse +{ + public bool IsSuccess { get; init; } + public int StatusCode { get; init; } + public T? Data { get; init; } + public string? ErrorMessage { get; init; } + public string? ErrorDetail { get; init; } + public Dictionary? ValidationErrors { get; init; } + public long DurationMs { get; init; } + public string RawRequestJson { get; init; } = string.Empty; + public string RawResponseJson { get; init; } = string.Empty; + + public static ApiResponse Success(T data, int statusCode = 200, long durationMs = 0) => + Success(data, statusCode, durationMs, string.Empty, string.Empty); + + public static ApiResponse Success(T data, int statusCode, long durationMs, string requestJson, string responseJson) => + new() + { + IsSuccess = true, + StatusCode = statusCode, + Data = data, + DurationMs = durationMs, + RawRequestJson = requestJson, + RawResponseJson = responseJson + }; + + public static ApiResponse Failure(int statusCode, string message, string? detail = null, long durationMs = 0) => + Failure(statusCode, message, detail, durationMs, string.Empty, string.Empty); + + public static ApiResponse Failure(int statusCode, string message, string? detail, long durationMs, string requestJson, string responseJson, Dictionary? validationErrors = null) => + new() + { + IsSuccess = false, + StatusCode = statusCode, + ErrorMessage = message, + ErrorDetail = detail, + ValidationErrors = validationErrors, + DurationMs = durationMs, + RawRequestJson = requestJson, + RawResponseJson = responseJson + }; +} diff --git a/desktop/SteamAPI.Desktop/Models/AppSettings.cs b/desktop/SteamAPI.Desktop/Models/AppSettings.cs new file mode 100644 index 0000000..45db83a --- /dev/null +++ b/desktop/SteamAPI.Desktop/Models/AppSettings.cs @@ -0,0 +1,13 @@ +namespace SteamAPI.Desktop.Models; + +/// +/// User and application settings for SteamAPI Desktop. +/// +public class AppSettings +{ + public string ApiBaseUrl { get; set; } = "http://localhost:5271"; + public int TimeoutSeconds { get; set; } = 30; + public bool DemoMode { get; set; } = false; + public bool AutoStartApi { get; set; } = false; + public string Theme { get; set; } = "Dark"; +} diff --git a/desktop/SteamAPI.Desktop/Models/SteamCulture.cs b/desktop/SteamAPI.Desktop/Models/SteamCulture.cs new file mode 100644 index 0000000..a2f7594 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Models/SteamCulture.cs @@ -0,0 +1,45 @@ +namespace SteamAPI.Desktop.Models; + +/// +/// Represents one of the 3 supported regional store cultures in SteamAPI v0.2.0. +/// +public class SteamCulture +{ + public int Id { get; init; } + public string DisplayName { get; init; } = string.Empty; + public string LanguageName { get; init; } = string.Empty; + public string CountryCode { get; init; } = string.Empty; + public string FlagEmoji { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + + public static readonly IReadOnlyList SupportedCultures = new List + { + new() + { + Id = 2, + DisplayName = "United States (English)", + LanguageName = "english", + CountryCode = "US", + FlagEmoji = "🇺🇸", + Description = "US Store (cc=us, lang=english)" + }, + new() + { + Id = 1, + DisplayName = "Turkey (Turkish)", + LanguageName = "turkish", + CountryCode = "TR", + FlagEmoji = "🇹🇷", + Description = "Turkish Store (cc=tr, lang=turkish)" + }, + new() + { + Id = 3, + DisplayName = "Spain (Spanish)", + LanguageName = "spanish", + CountryCode = "ES", + FlagEmoji = "🇪🇸", + Description = "Spanish Store (cc=es, lang=spanish)" + } + }; +} diff --git a/desktop/SteamAPI.Desktop/Models/SteamGameListing.cs b/desktop/SteamAPI.Desktop/Models/SteamGameListing.cs new file mode 100644 index 0000000..a434d45 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Models/SteamGameListing.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace SteamAPI.Desktop.Models; + +/// +/// Represents a game listing returned by the SteamAPI POST /steam endpoint. +/// Conforms to the verified 6-field contract: name, storeLink, imageLink, price, saleType, appId. +/// +public class SteamGameListing +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("storeLink")] + public string StoreLink { get; set; } = string.Empty; + + [JsonPropertyName("imageLink")] + public string ImageLink { get; set; } = string.Empty; + + [JsonPropertyName("price")] + public double? Price { get; set; } + + [JsonPropertyName("saleType")] + public int SaleType { get; set; } + + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + /// + /// Human-readable display text for the price or availability state. + /// Does NOT fabricate currency symbols or unverified currency metadata. + /// + [JsonIgnore] + public string DisplayPrice + { + get + { + return SaleType switch + { + 1 => "Free to Play", + 2 => "Not Available", + 0 => Price.HasValue ? Price.Value.ToString("0.00", CultureInfo.InvariantCulture) : "Paid", + _ => Price.HasValue ? Price.Value.ToString("0.00", CultureInfo.InvariantCulture) : "Unknown" + }; + } + } + + [JsonIgnore] + public string SaleTypeBadgeText + { + get + { + return SaleType switch + { + 0 => "PAID", + 1 => "FREE TO PLAY", + 2 => "NOT AVAILABLE", + _ => "UNKNOWN" + }; + } + } + + [JsonIgnore] + public bool IsFree => SaleType == 1; + + [JsonIgnore] + public bool IsPaid => SaleType == 0; + + [JsonIgnore] + public bool IsUnavailable => SaleType == 2; + + [JsonIgnore] + public string SteamProtocolUrl => !string.IsNullOrWhiteSpace(AppId) + ? $"steam://store/{AppId}" + : StoreLink; +} diff --git a/desktop/SteamAPI.Desktop/Program.cs b/desktop/SteamAPI.Desktop/Program.cs new file mode 100644 index 0000000..effe3cd --- /dev/null +++ b/desktop/SteamAPI.Desktop/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using System; + +namespace SteamAPI.Desktop; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/desktop/SteamAPI.Desktop/Services/ApiProcessManager.cs b/desktop/SteamAPI.Desktop/Services/ApiProcessManager.cs new file mode 100644 index 0000000..36dcaf1 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/ApiProcessManager.cs @@ -0,0 +1,476 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; + +namespace SteamAPI.Desktop.Services; + +public class ApiProcessManager : IApiProcessManager +{ + private Process? _process; + private IntPtr _jobHandle = IntPtr.Zero; + private CancellationTokenSource? _startupCts; + private readonly object _lock = new(); + private bool _isStopping; + + public ApiProcessState State { get; private set; } = ApiProcessState.Stopped; + public string? LastError { get; private set; } + + public int? ProcessId + { + get + { + lock (_lock) + { + return _process != null && !SafeHasExited(_process) ? TryGetProcessId(_process) : null; + } + } + } + + public string BoundUrl { get; private set; } = "http://127.0.0.1:5271"; + public event Action? OnStateChanged; + + public async Task StartAsync(string? executablePath = null, CancellationToken cancellationToken = default) + { + CancellationToken linkedToken; + lock (_lock) + { + if (State == ApiProcessState.Running && _process != null && !SafeHasExited(_process)) + { + return true; + } + + if (State == ApiProcessState.Starting) + { + return false; + } + + _isStopping = false; + try { _startupCts?.Cancel(); } catch { } + try { _startupCts?.Dispose(); } catch { } + _startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedToken = _startupCts.Token; + State = ApiProcessState.Starting; + } + + OnStateChanged?.Invoke(ApiProcessState.Starting); + + Process? proc = null; + try + { + // Check if port is already in use by another application + if (await IsPortInUseAsync("127.0.0.1", 5271, linkedToken).ConfigureAwait(false)) + { + LastError = "Port 5271 is already in use by another application. Companion SteamAPI cannot be started. Please free port 5271 or configure an external API URL in Settings."; + CleanupOwnedResources(); + SetState(ApiProcessState.Failed); + return false; + } + + var resolvedPath = ResolveApiExecutablePath(executablePath); + if (resolvedPath == null) + { + LastError = "Could not locate SteamAPI executable or DLL. Please start SteamAPI manually or configure path in Settings."; + SetState(ApiProcessState.Failed); + return false; + } + + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = false, + RedirectStandardError = false, + WorkingDirectory = Path.GetDirectoryName(resolvedPath) ?? AppContext.BaseDirectory + }; + + if (resolvedPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + startInfo.FileName = "dotnet"; + startInfo.Arguments = $"\"{resolvedPath}\" --urls http://127.0.0.1:5271"; + } + else + { + startInfo.FileName = resolvedPath; + startInfo.Arguments = "--urls http://127.0.0.1:5271"; + } + + proc = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + proc.Exited += OnProcessExited; + proc.Start(); + + bool abortImmediately = false; + lock (_lock) + { + _process = proc; + if (_isStopping || linkedToken.IsCancellationRequested) + { + abortImmediately = true; + } + else if (OperatingSystem.IsWindows()) + { + if (_jobHandle == IntPtr.Zero) + { + _jobHandle = WindowsJobObject.CreateJobWithKillOnClose(); + } + WindowsJobObject.AssignProcess(_jobHandle, proc); + } + } + + if (abortImmediately) + { + CleanupOwnedResources(); + SetState(ApiProcessState.Stopped, notifyListeners: false); + return false; + } + + // Wait for port to become active (up to 10 seconds) + for (int i = 0; i < 20; i++) + { + await Task.Delay(500, linkedToken).ConfigureAwait(false); + if (await IsPortInUseAsync("127.0.0.1", 5271, linkedToken).ConfigureAwait(false)) + { + if (_isStopping) + { + return false; + } + SetState(ApiProcessState.Running); + return true; + } + + if (SafeHasExited(proc)) + { + if (_isStopping) + { + return false; + } + var exitCode = TryGetExitCode(proc); + LastError = exitCode.HasValue + ? $"SteamAPI failed to start (exited with code {exitCode.Value})." + : "SteamAPI failed to start."; + CleanupOwnedResources(); + SetState(ApiProcessState.Failed); + return false; + } + } + + if (_isStopping) + { + return false; + } + LastError = "SteamAPI process started but did not bind to port 5271 within 10 seconds."; + CleanupOwnedResources(); + SetState(ApiProcessState.Failed); + return false; + } + catch (OperationCanceledException) + { + CleanupOwnedResources(); + SetState(ApiProcessState.Stopped, notifyListeners: false); + return false; + } + catch (Exception ex) + { + if (proc != null && _process == null) + { + TerminateAndDisposeProcess(proc); + } + CleanupOwnedResources(); + if (!_isStopping) + { + LastError = $"Failed to start child API process: {ex.Message}"; + SetState(ApiProcessState.Failed); + } + return false; + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + if (sender is not Process exitedProcess) return; + + bool shouldNotifyStopped = false; + lock (_lock) + { + if (_isStopping || !ReferenceEquals(_process, exitedProcess)) + { + return; + } + + var exitCode = TryGetExitCode(exitedProcess); + LastError = exitCode.HasValue + ? $"SteamAPI child process exited unexpectedly with code {exitCode.Value}." + : "SteamAPI child process exited unexpectedly."; + State = ApiProcessState.Stopped; + shouldNotifyStopped = true; + } + + if (shouldNotifyStopped) + { + OnStateChanged?.Invoke(ApiProcessState.Stopped); + } + } + + public Task StopAsync() + { + CleanupOwnedResources(); + SetState(ApiProcessState.Stopped); + return Task.CompletedTask; + } + + public void Dispose() + { + StopAsync().GetAwaiter().GetResult(); + GC.SuppressFinalize(this); + } + + private void CleanupOwnedResources() + { + Process? procToStop; + IntPtr jobToClose; + CancellationTokenSource? ctsToCancel; + + lock (_lock) + { + _isStopping = true; + procToStop = _process; + _process = null; + jobToClose = _jobHandle; + _jobHandle = IntPtr.Zero; + ctsToCancel = _startupCts; + _startupCts = null; + } + + try { ctsToCancel?.Cancel(); } catch { } + try { ctsToCancel?.Dispose(); } catch { } + + if (procToStop != null) + { + TerminateAndDisposeProcess(procToStop); + } + + if (jobToClose != IntPtr.Zero) + { + WindowsJobObject.CloseJob(jobToClose); + } + } + + private void TerminateAndDisposeProcess(Process proc) + { + try { proc.Exited -= OnProcessExited; } catch { } + try + { + if (!SafeHasExited(proc)) + { + proc.Kill(entireProcessTree: true); + proc.WaitForExit(3000); + } + } + catch + { + // Ignore kill/wait errors on shutdown + } + finally + { + try { proc.Dispose(); } catch { } + } + } + + private static bool SafeHasExited(Process proc) + { + try { return proc.HasExited; } + catch { return true; } + } + + private static int? TryGetExitCode(Process proc) + { + try { return proc.ExitCode; } + catch { return null; } + } + + private static int? TryGetProcessId(Process proc) + { + try { return proc.Id; } + catch { return null; } + } + + private void SetState(ApiProcessState state, bool notifyListeners = true) + { + bool changed; + lock (_lock) + { + changed = State != state; + State = state; + } + if (changed && notifyListeners) + { + OnStateChanged?.Invoke(state); + } + } + + private static async Task IsPortInUseAsync(string host, int port, CancellationToken cancellationToken) + { + try + { + using var tcpClient = new TcpClient(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(400)); + await tcpClient.ConnectAsync(host, port, timeoutCts.Token).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + private static string? ResolveApiExecutablePath(string? customPath) + { + if (!string.IsNullOrWhiteSpace(customPath)) + { + return File.Exists(customPath) ? customPath : null; + } + + var baseDir = AppContext.BaseDirectory; + + // Check packaged / sibling directories + var candidates = new[] + { + Path.Combine(baseDir, "api", "SteamAPI.exe"), + Path.Combine(baseDir, "api", "SteamAPI.dll"), + Path.Combine(baseDir, "SteamAPI.exe"), + Path.Combine(baseDir, "SteamAPI.dll"), + // Development fallback relative to solution + Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "SteamAPI", "bin", "Debug", "net10.0", "SteamAPI.dll")), + Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "SteamAPI", "bin", "Release", "net10.0", "SteamAPI.dll")) + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + + return null; + } + + #region Win32 Job Object Interop + + private static class WindowsJobObject + { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInformationClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryLimit; + public UIntPtr PeakJobMemoryLimit; + } + + public static IntPtr CreateJobWithKillOnClose() + { + if (!OperatingSystem.IsWindows()) return IntPtr.Zero; + + try + { + var hJob = CreateJobObject(IntPtr.Zero, null); + if (hJob == IntPtr.Zero) return IntPtr.Zero; + + var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + var length = (uint)Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + var ptr = Marshal.AllocHGlobal((int)length); + try + { + Marshal.StructureToPtr(info, ptr, false); + if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, ptr, length)) + { + CloseHandle(hJob); + return IntPtr.Zero; + } + } + finally + { + Marshal.FreeHGlobal(ptr); + } + + return hJob; + } + catch + { + return IntPtr.Zero; + } + } + + public static bool AssignProcess(IntPtr hJob, Process process) + { + if (!OperatingSystem.IsWindows() || hJob == IntPtr.Zero) return false; + try + { + return AssignProcessToJobObject(hJob, process.Handle); + } + catch + { + return false; + } + } + + public static void CloseJob(IntPtr hJob) + { + if (!OperatingSystem.IsWindows() || hJob == IntPtr.Zero) return; + try + { + CloseHandle(hJob); + } + catch { } + } + } + + #endregion +} diff --git a/desktop/SteamAPI.Desktop/Services/DemoDataService.cs b/desktop/SteamAPI.Desktop/Services/DemoDataService.cs new file mode 100644 index 0000000..7d29011 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/DemoDataService.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using SteamAPI.Desktop.Models; + +namespace SteamAPI.Desktop.Services; + +public class DemoDataService : IDemoDataService +{ + private static readonly IReadOnlyList BaseSyntheticCatalog = new List + { + new() + { + Name = "Portal: Quantum Paradox [SYNTHETIC DEMO]", + AppId = "999001", + StoreLink = "https://store.steampowered.com/app/999001/Portal_Quantum_Paradox_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/400/header.jpg", + Price = 9.99, + SaleType = 0 + }, + new() + { + Name = "Counter-Strike: Tactical Protocol [SYNTHETIC DEMO]", + AppId = "999002", + StoreLink = "https://store.steampowered.com/app/999002/Counter_Strike_Tactical_Protocol_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/730/header.jpg", + Price = null, + SaleType = 1 // Free to Play + }, + new() + { + Name = "Half-Life: Echoes of Black Mesa [SYNTHETIC DEMO]", + AppId = "999003", + StoreLink = "https://store.steampowered.com/app/999003/Half_Life_Echoes_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/220/header.jpg", + Price = 19.99, + SaleType = 0 + }, + new() + { + Name = "Chronicles of Nebula [SYNTHETIC DEMO]", + AppId = "999004", + StoreLink = "https://store.steampowered.com/app/999004/Chronicles_of_Nebula_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/620/header.jpg", + Price = 14.99, + SaleType = 0 + }, + new() + { + Name = "Cyber Arena Champions [SYNTHETIC DEMO]", + AppId = "999005", + StoreLink = "https://store.steampowered.com/app/999005/Cyber_Arena_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/570/header.jpg", + Price = null, + SaleType = 1 // Free to Play + }, + new() + { + Name = "Project Aether (Unannounced) [SYNTHETIC DEMO]", + AppId = "999006", + StoreLink = "https://store.steampowered.com/app/999006/Project_Aether_Demo", + ImageLink = "", + Price = null, + SaleType = 2 // Not Available + } + }; + + public Task>> SearchSyntheticGamesAsync( + string query, + int culture, + CancellationToken cancellationToken = default) + { + var cleanQuery = (query ?? string.Empty).Trim(); + var rawMatches = string.IsNullOrWhiteSpace(cleanQuery) + ? BaseSyntheticCatalog.ToList() + : BaseSyntheticCatalog + .Where(g => g.Name.Contains(cleanQuery, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // If no direct keyword match, generate dynamic realistic synthetic items for the user's query + if (rawMatches.Count == 0 && !string.IsNullOrWhiteSpace(cleanQuery)) + { + rawMatches = new List + { + new() + { + Name = $"{cleanQuery} [SYNTHETIC DEMO]", + AppId = "999101", + StoreLink = $"https://store.steampowered.com/app/999101/{Uri.EscapeDataString(cleanQuery)}_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/400/header.jpg", + Price = 19.99, + SaleType = 0 + }, + new() + { + Name = $"{cleanQuery} Deluxe Edition [SYNTHETIC DEMO]", + AppId = "999102", + StoreLink = $"https://store.steampowered.com/app/999102/{Uri.EscapeDataString(cleanQuery)}_Deluxe_Demo", + ImageLink = "https://cdn.akamai.steamstatic.com/steam/apps/620/header.jpg", + Price = 29.99, + SaleType = 0 + } + }; + } + + // Apply regional culture adjustments (e.g. Turkey culture 1 regional pricing) + var adjustedMatches = rawMatches.Select(g => + { + double? adjustedPrice = g.Price; + if (g.Price.HasValue) + { + adjustedPrice = culture switch + { + 1 => Math.Round(g.Price.Value * 0.60, 2), // Turkey MENA regional discount + 3 => Math.Round(g.Price.Value * 0.92, 2), // EUR exchange representation + _ => g.Price.Value // US baseline USD + }; + } + + return new SteamGameListing + { + Name = g.Name, + AppId = g.AppId, + StoreLink = g.StoreLink, + ImageLink = g.ImageLink, + SaleType = g.SaleType, + Price = adjustedPrice + }; + }).ToList(); + + var requestJson = JsonSerializer.Serialize(new + { + game = cleanQuery, + culture, + _demoNote = "OFFLINE DEMO MODE - Synthetic simulation only" + }, new JsonSerializerOptions { WriteIndented = true }); + + var responseJson = JsonSerializer.Serialize(adjustedMatches, new JsonSerializerOptions { WriteIndented = true }); + + var response = ApiResponse>.Success( + adjustedMatches, + 200, + 15, // simulated 15ms duration + requestJson, + responseJson); + + return Task.FromResult(response); + } +} diff --git a/desktop/SteamAPI.Desktop/Services/DesktopPlatformServices.cs b/desktop/SteamAPI.Desktop/Services/DesktopPlatformServices.cs new file mode 100644 index 0000000..ef65030 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/DesktopPlatformServices.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; + +namespace SteamAPI.Desktop.Services; + +public class DesktopClipboardService : IClipboardService +{ + public async Task SetTextAsync(string text) + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && + desktop.MainWindow?.Clipboard != null) + { + await desktop.MainWindow.Clipboard.SetTextAsync(text ?? string.Empty).ConfigureAwait(false); + } + } +} + +public class DesktopLauncherService : ILauncherService +{ + public Task LaunchUriAsync(Uri uri) + { + try + { + var psi = new ProcessStartInfo + { + FileName = uri.ToString(), + UseShellExecute = true + }; + Process.Start(psi); + return Task.FromResult(true); + } + catch + { + return Task.FromResult(false); + } + } +} diff --git a/desktop/SteamAPI.Desktop/Services/IApiProcessManager.cs b/desktop/SteamAPI.Desktop/Services/IApiProcessManager.cs new file mode 100644 index 0000000..d4482dc --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/IApiProcessManager.cs @@ -0,0 +1,21 @@ +namespace SteamAPI.Desktop.Services; + +public enum ApiProcessState +{ + Stopped, + Starting, + Running, + Failed +} + +public interface IApiProcessManager : IDisposable +{ + ApiProcessState State { get; } + string? LastError { get; } + int? ProcessId { get; } + string BoundUrl { get; } + event Action? OnStateChanged; + + Task StartAsync(string? executablePath = null, CancellationToken cancellationToken = default); + Task StopAsync(); +} diff --git a/desktop/SteamAPI.Desktop/Services/IClipboardService.cs b/desktop/SteamAPI.Desktop/Services/IClipboardService.cs new file mode 100644 index 0000000..9c56ffd --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/IClipboardService.cs @@ -0,0 +1,6 @@ +namespace SteamAPI.Desktop.Services; + +public interface IClipboardService +{ + Task SetTextAsync(string text); +} diff --git a/desktop/SteamAPI.Desktop/Services/IDemoDataService.cs b/desktop/SteamAPI.Desktop/Services/IDemoDataService.cs new file mode 100644 index 0000000..4eb412f --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/IDemoDataService.cs @@ -0,0 +1,11 @@ +using SteamAPI.Desktop.Models; + +namespace SteamAPI.Desktop.Services; + +public interface IDemoDataService +{ + Task>> SearchSyntheticGamesAsync( + string query, + int culture, + CancellationToken cancellationToken = default); +} diff --git a/desktop/SteamAPI.Desktop/Services/ILauncherService.cs b/desktop/SteamAPI.Desktop/Services/ILauncherService.cs new file mode 100644 index 0000000..218e969 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/ILauncherService.cs @@ -0,0 +1,6 @@ +namespace SteamAPI.Desktop.Services; + +public interface ILauncherService +{ + Task LaunchUriAsync(Uri uri); +} diff --git a/desktop/SteamAPI.Desktop/Services/ISettingsService.cs b/desktop/SteamAPI.Desktop/Services/ISettingsService.cs new file mode 100644 index 0000000..8e68705 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/ISettingsService.cs @@ -0,0 +1,10 @@ +using SteamAPI.Desktop.Models; + +namespace SteamAPI.Desktop.Services; + +public interface ISettingsService +{ + AppSettings CurrentSettings { get; } + void SaveSettings(AppSettings settings); + void ResetToDefaults(); +} diff --git a/desktop/SteamAPI.Desktop/Services/ISteamApiClient.cs b/desktop/SteamAPI.Desktop/Services/ISteamApiClient.cs new file mode 100644 index 0000000..a050590 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/ISteamApiClient.cs @@ -0,0 +1,17 @@ +using SteamAPI.Desktop.Models; + +namespace SteamAPI.Desktop.Services; + +public interface ISteamApiClient +{ + string BaseUrl { get; set; } + int TimeoutSeconds { get; set; } + event Action? OnInspectionRecordCreated; + + Task>> SearchGamesAsync( + string game, + int culture, + CancellationToken cancellationToken = default); + + Task> CheckConnectionAsync(CancellationToken cancellationToken = default); +} diff --git a/desktop/SteamAPI.Desktop/Services/SettingsService.cs b/desktop/SteamAPI.Desktop/Services/SettingsService.cs new file mode 100644 index 0000000..6ec0739 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/SettingsService.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using SteamAPI.Desktop.Models; + +namespace SteamAPI.Desktop.Services; + +public class SettingsService : ISettingsService +{ + private readonly string _settingsFilePath; + private AppSettings _currentSettings; + + public AppSettings CurrentSettings => _currentSettings; + + public SettingsService(string? customFilePath = null) + { + if (customFilePath != null) + { + _settingsFilePath = customFilePath; + } + else + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var folder = Path.Combine(appData, "SteamAPI.Desktop"); + _settingsFilePath = Path.Combine(folder, "settings.json"); + } + + _currentSettings = LoadSettings(); + } + + private AppSettings LoadSettings() + { + try + { + if (File.Exists(_settingsFilePath)) + { + var json = File.ReadAllText(_settingsFilePath); + var loaded = JsonSerializer.Deserialize(json); + if (loaded != null) + { + return loaded; + } + } + } + catch + { + // Fall back to defaults on corrupt file or read error + } + + return new AppSettings(); + } + + public void SaveSettings(AppSettings settings) + { + _currentSettings = settings ?? new AppSettings(); + try + { + var folder = Path.GetDirectoryName(_settingsFilePath); + if (!string.IsNullOrWhiteSpace(folder) && !Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + var json = JsonSerializer.Serialize(_currentSettings, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_settingsFilePath, json); + } + catch + { + // Suppress IO failure in restricted sandbox environments + } + } + + public void ResetToDefaults() + { + SaveSettings(new AppSettings()); + } +} diff --git a/desktop/SteamAPI.Desktop/Services/SteamApiClient.cs b/desktop/SteamAPI.Desktop/Services/SteamApiClient.cs new file mode 100644 index 0000000..1f9aefd --- /dev/null +++ b/desktop/SteamAPI.Desktop/Services/SteamApiClient.cs @@ -0,0 +1,259 @@ +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using SteamAPI.Desktop.Models; + +namespace SteamAPI.Desktop.Services; + +public class SteamApiClient : ISteamApiClient, IDisposable +{ + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public string BaseUrl { get; set; } = "http://localhost:5271"; + public int TimeoutSeconds { get; set; } = 30; + public event Action? OnInspectionRecordCreated; + + public SteamApiClient(HttpClient? httpClient = null) + { + _ownsHttpClient = httpClient == null; + _httpClient = httpClient ?? new HttpClient(); + } + + public SteamApiClient(string baseUrl, int timeoutSeconds = 30, HttpClient? httpClient = null) + : this(httpClient) + { + BaseUrl = baseUrl; + TimeoutSeconds = timeoutSeconds; + } + + public async Task>> SearchGamesAsync( + string game, + int culture, + CancellationToken cancellationToken = default) + { + var requestPayload = new + { + game = game ?? string.Empty, + culture + }; + + var requestJson = JsonSerializer.Serialize(requestPayload, JsonOptions); + var targetUri = new Uri(new Uri(BaseUrl.TrimEnd('/') + "/"), "steam"); + var stopwatch = Stopwatch.StartNew(); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, targetUri) + { + Content = new StringContent(requestJson, Encoding.UTF8, "application/json") + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(TimeoutSeconds)); + + using var response = await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false); + stopwatch.Stop(); + var durationMs = stopwatch.ElapsedMilliseconds; + + var responseRaw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var prettyResponseJson = FormatJsonIfPossible(responseRaw); + + if (response.IsSuccessStatusCode) + { + var listings = JsonSerializer.Deserialize>(responseRaw, JsonOptions) + ?? new List(); + + var record = new ApiInspectionRecord + { + RequestUrl = targetUri.ToString(), + HttpMethod = "POST", + RequestBodyJson = requestJson, + ResponseStatusCode = (int)response.StatusCode, + ResponseStatusText = $"{(int)response.StatusCode} {response.ReasonPhrase}", + DurationMs = durationMs, + ResponseBodyJson = prettyResponseJson, + ItemCount = listings.Count, + IsDemoMode = false + }; + OnInspectionRecordCreated?.Invoke(record); + + return ApiResponse>.Success( + listings, + (int)response.StatusCode, + durationMs, + requestJson, + prettyResponseJson); + } + else + { + // Error handling for 400, 502, 504 and others + string title = response.ReasonPhrase ?? "API Error"; + string? detail = null; + Dictionary? errors = null; + + try + { + using var doc = JsonDocument.Parse(responseRaw); + var root = doc.RootElement; + if (root.TryGetProperty("title", out var titleEl)) + { + title = titleEl.GetString() ?? title; + } + if (root.TryGetProperty("detail", out var detailEl)) + { + detail = detailEl.GetString(); + } + if (root.TryGetProperty("errors", out var errorsEl) && errorsEl.ValueKind == JsonValueKind.Object) + { + errors = new Dictionary(); + foreach (var prop in errorsEl.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.Array) + { + var errList = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + if (item.GetString() is string s) errList.Add(s); + } + errors[prop.Name] = errList.ToArray(); + } + } + } + } + catch + { + // If not valid JSON, use raw string + detail = responseRaw; + } + + var record = new ApiInspectionRecord + { + RequestUrl = targetUri.ToString(), + HttpMethod = "POST", + RequestBodyJson = requestJson, + ResponseStatusCode = (int)response.StatusCode, + ResponseStatusText = $"{(int)response.StatusCode} {response.ReasonPhrase}", + DurationMs = durationMs, + ResponseBodyJson = prettyResponseJson, + ErrorMessage = $"{title}{(detail != null ? $": {detail}" : "")}", + ItemCount = 0, + IsDemoMode = false + }; + OnInspectionRecordCreated?.Invoke(record); + + return ApiResponse>.Failure( + (int)response.StatusCode, + title, + detail, + durationMs, + requestJson, + prettyResponseJson, + errors); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + stopwatch.Stop(); + var durationMs = stopwatch.ElapsedMilliseconds; + var errorMsg = $"Request timed out after {TimeoutSeconds} seconds connecting to {targetUri}."; + + var record = new ApiInspectionRecord + { + RequestUrl = targetUri.ToString(), + HttpMethod = "POST", + RequestBodyJson = requestJson, + ResponseStatusCode = 504, + ResponseStatusText = "504 Gateway Timeout (Client-Enforced)", + DurationMs = durationMs, + ResponseBodyJson = $"{{\n \"error\": \"Client-side timeout reached ({TimeoutSeconds}s).\"\n}}", + ErrorMessage = errorMsg, + IsDemoMode = false + }; + OnInspectionRecordCreated?.Invoke(record); + + return ApiResponse>.Failure( + 504, + "Timeout", + errorMsg, + durationMs, + requestJson, + record.ResponseBodyJson); + } + catch (Exception ex) + { + stopwatch.Stop(); + var durationMs = stopwatch.ElapsedMilliseconds; + var errorMsg = $"Unable to connect to SteamAPI at '{targetUri}': {ex.Message}"; + + var record = new ApiInspectionRecord + { + RequestUrl = targetUri.ToString(), + HttpMethod = "POST", + RequestBodyJson = requestJson, + ResponseStatusCode = 0, + ResponseStatusText = "Connection Failed", + DurationMs = durationMs, + ResponseBodyJson = $"{{\n \"error\": \"{JsonEncodedText.Encode(ex.Message)}\"\n}}", + ErrorMessage = errorMsg, + IsDemoMode = false + }; + OnInspectionRecordCreated?.Invoke(record); + + return ApiResponse>.Failure( + 0, + "Connection Failed", + errorMsg, + durationMs, + requestJson, + record.ResponseBodyJson); + } + } + + public async Task> CheckConnectionAsync(CancellationToken cancellationToken = default) + { + // Tests connection to POST /steam with a harmless query "Portal" + var result = await SearchGamesAsync("Portal", 2, cancellationToken).ConfigureAwait(false); + return new ApiResponse + { + IsSuccess = result.IsSuccess, + StatusCode = result.StatusCode, + Data = result.IsSuccess, + ErrorMessage = result.ErrorMessage, + ErrorDetail = result.ErrorDetail, + DurationMs = result.DurationMs, + RawRequestJson = result.RawRequestJson, + RawResponseJson = result.RawResponseJson + }; + } + + private static string FormatJsonIfPossible(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + try + { + using var doc = JsonDocument.Parse(raw); + return JsonSerializer.Serialize(doc.RootElement, JsonOptions); + } + catch + { + return raw; + } + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + GC.SuppressFinalize(this); + } +} diff --git a/desktop/SteamAPI.Desktop/SteamAPI.Desktop.csproj b/desktop/SteamAPI.Desktop/SteamAPI.Desktop.csproj new file mode 100644 index 0000000..a349a60 --- /dev/null +++ b/desktop/SteamAPI.Desktop/SteamAPI.Desktop.csproj @@ -0,0 +1,38 @@ + + + WinExe + net10.0 + enable + enable + app.manifest + true + + + 0.1.0 + 0.1.0.0 + 0.1.0.0 + SteamAPI Desktop + Fatih Emre Sarman + Fatih Emre Sarman + Copyright © 2026 Fatih Emre Sarman + Standalone cross-platform desktop game explorer and telemetry inspector for SteamAPI + https://github.com/fatihemres/SteamAPI + + + + + + + + + + + + + + None + All + + + + diff --git a/desktop/SteamAPI.Desktop/ViewLocator.cs b/desktop/SteamAPI.Desktop/ViewLocator.cs new file mode 100644 index 0000000..f277877 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using SteamAPI.Desktop.ViewModels; + +namespace SteamAPI.Desktop; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name) ?? typeof(App).Assembly.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/desktop/SteamAPI.Desktop/ViewModels/AboutViewModel.cs b/desktop/SteamAPI.Desktop/ViewModels/AboutViewModel.cs new file mode 100644 index 0000000..c3919e4 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewModels/AboutViewModel.cs @@ -0,0 +1,54 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SteamAPI.Desktop.Services; + +namespace SteamAPI.Desktop.ViewModels; + +public partial class AboutViewModel : ViewModelBase +{ + private readonly ILauncherService _launcherService; + + public string AppVersion => "v0.1.0 (Release Candidate)"; + public string ApiCompatibilityVersion => "SteamAPI v0.2.0 (.NET 10)"; + public string FrameworkVersion => ".NET 10.0 (C# 14, Avalonia UI 11.2)"; + public string LicenseType => "MIT License"; + + public string GitHubRepoUrl => "https://github.com/fatihemres/SteamAPI"; + public string GitHubReleasesUrl => "https://github.com/fatihemres/SteamAPI/releases"; + public string ApiDocsUrl => "https://github.com/fatihemres/SteamAPI/blob/master/docs/API.md"; + + public string CreatorTitle => "Designed & Developed by"; + public string CreatorName => "Fatih Emre Sarman"; + public string CreatorGitHub => "@fatihemres"; + public string CreatorProfileUrl => "https://github.com/fatihemres"; + public string CopyrightNotice => "Copyright © 2026 Fatih Emre Sarman"; + + public AboutViewModel(ILauncherService launcherService) + { + _launcherService = launcherService; + } + + [RelayCommand] + public async Task OpenCreatorProfileAsync() + { + await _launcherService.LaunchUriAsync(new Uri(CreatorProfileUrl)).ConfigureAwait(false); + } + + [RelayCommand] + public async Task OpenGitHubRepoAsync() + { + await _launcherService.LaunchUriAsync(new Uri(GitHubRepoUrl)).ConfigureAwait(false); + } + + [RelayCommand] + public async Task OpenGitHubReleasesAsync() + { + await _launcherService.LaunchUriAsync(new Uri(GitHubReleasesUrl)).ConfigureAwait(false); + } + + [RelayCommand] + public async Task OpenApiDocsAsync() + { + await _launcherService.LaunchUriAsync(new Uri(ApiDocsUrl)).ConfigureAwait(false); + } +} diff --git a/desktop/SteamAPI.Desktop/ViewModels/ApiInspectorViewModel.cs b/desktop/SteamAPI.Desktop/ViewModels/ApiInspectorViewModel.cs new file mode 100644 index 0000000..892d2a1 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewModels/ApiInspectorViewModel.cs @@ -0,0 +1,102 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; + +namespace SteamAPI.Desktop.ViewModels; + +public partial class ApiInspectorViewModel : ViewModelBase +{ + private readonly IClipboardService _clipboardService; + + [ObservableProperty] + private ApiInspectionRecord? _selectedRecord; + + [ObservableProperty] + private string? _copyFeedbackMessage; + + public ObservableCollection Records { get; } = new(); + + public ApiInspectorViewModel(IClipboardService clipboardService, ISteamApiClient? apiClient = null) + { + _clipboardService = clipboardService; + + if (apiClient != null) + { + apiClient.OnInspectionRecordCreated += AddRecord; + } + } + + public void AddRecord(ApiInspectionRecord record) + { + if (Application.Current != null && !Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => AddRecordCore(record)); + return; + } + + AddRecordCore(record); + } + + private void AddRecordCore(ApiInspectionRecord record) + { + // Insert newest record at top + Records.Insert(0, record); + + // Keep maximum 50 records in memory + if (Records.Count > 50) + { + Records.RemoveAt(Records.Count - 1); + } + + // Automatically select the newest record if none selected or if previously on top + SelectedRecord = record; + CopyFeedbackMessage = null; + } + + [RelayCommand] + public async Task CopyRequestJsonAsync() + { + if (SelectedRecord != null && !string.IsNullOrWhiteSpace(SelectedRecord.RequestBodyJson)) + { + await _clipboardService.SetTextAsync(SelectedRecord.RequestBodyJson); + ShowFeedback("Request JSON copied to clipboard"); + } + } + + [RelayCommand] + public async Task CopyResponseJsonAsync() + { + if (SelectedRecord != null && !string.IsNullOrWhiteSpace(SelectedRecord.ResponseBodyJson)) + { + await _clipboardService.SetTextAsync(SelectedRecord.ResponseBodyJson); + ShowFeedback("Response JSON copied to clipboard"); + } + } + + [RelayCommand] + public async Task CopyEndpointUrlAsync() + { + if (SelectedRecord != null && !string.IsNullOrWhiteSpace(SelectedRecord.RequestUrl)) + { + await _clipboardService.SetTextAsync(SelectedRecord.RequestUrl); + ShowFeedback("Endpoint URL copied to clipboard"); + } + } + + [RelayCommand] + public void ClearHistory() + { + Records.Clear(); + SelectedRecord = null; + CopyFeedbackMessage = null; + } + + private void ShowFeedback(string msg) + { + CopyFeedbackMessage = msg; + } +} diff --git a/desktop/SteamAPI.Desktop/ViewModels/GameExplorerViewModel.cs b/desktop/SteamAPI.Desktop/ViewModels/GameExplorerViewModel.cs new file mode 100644 index 0000000..1d629f8 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewModels/GameExplorerViewModel.cs @@ -0,0 +1,250 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; + +namespace SteamAPI.Desktop.ViewModels; + +public partial class GameExplorerViewModel : ViewModelBase +{ + private readonly ISteamApiClient _apiClient; + private readonly IDemoDataService _demoDataService; + private readonly ISettingsService _settingsService; + private readonly IClipboardService _clipboardService; + private readonly ILauncherService _launcherService; + private CancellationTokenSource? _searchCts; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SearchCommand))] + private string _searchQuery = string.Empty; + + [ObservableProperty] + private SteamCulture _selectedCulture; + + [ObservableProperty] + private bool _isSearching; + + [ObservableProperty] + private bool _hasSearched; + + [ObservableProperty] + private string? _errorMessage; + + [ObservableProperty] + private string? _errorDetail; + + [ObservableProperty] + private string? _statusMessage; + + [ObservableProperty] + private SteamGameListing? _selectedListing; + + [ObservableProperty] + private bool _isDemoMode; + + [ObservableProperty] + private string? _copiedFeedbackMessage; + + public IReadOnlyList Cultures => SteamCulture.SupportedCultures; + public ObservableCollection Listings { get; } = new(); + + public GameExplorerViewModel( + ISteamApiClient apiClient, + IDemoDataService demoDataService, + ISettingsService settingsService, + IClipboardService clipboardService, + ILauncherService launcherService) + { + _apiClient = apiClient; + _demoDataService = demoDataService; + _settingsService = settingsService; + _clipboardService = clipboardService; + _launcherService = launcherService; + + _selectedCulture = Cultures.FirstOrDefault(c => c.Id == 2) ?? Cultures[0]; + _isDemoMode = _settingsService.CurrentSettings.DemoMode; + } + + public bool CanSearch => !IsSearching && !string.IsNullOrWhiteSpace(SearchQuery) && SearchQuery.Length <= 200; + + [RelayCommand(CanExecute = nameof(CanSearch))] + public async Task SearchAsync() + { + var query = SearchQuery.Trim(); + if (string.IsNullOrWhiteSpace(query)) + { + ErrorMessage = "Please enter a game title to search."; + return; + } + + if (query.Length > 200) + { + ErrorMessage = "The search query cannot exceed 200 characters."; + return; + } + + try { _searchCts?.Cancel(); } catch { } + try { _searchCts?.Dispose(); } catch { } + var cts = new CancellationTokenSource(); + _searchCts = cts; + var ct = cts.Token; + + IsSearching = true; + ErrorMessage = null; + ErrorDetail = null; + StatusMessage = "Searching Steam catalog..."; + CopiedFeedbackMessage = null; + + try + { + ApiResponse> response; + + if (IsDemoMode) + { + response = await _demoDataService.SearchSyntheticGamesAsync(query, SelectedCulture.Id, ct); + } + else + { + response = await _apiClient.SearchGamesAsync(query, SelectedCulture.Id, ct); + } + + if (ct.IsCancellationRequested) return; + + Listings.Clear(); + + if (response.IsSuccess && response.Data != null) + { + foreach (var item in response.Data) + { + Listings.Add(item); + } + + HasSearched = true; + if (Listings.Count == 0) + { + StatusMessage = $"No matching games found on Steam for \"{query}\" ({SelectedCulture.CountryCode})."; + } + else + { + var demoBadge = IsDemoMode ? " [OFFLINE DEMO]" : ""; + StatusMessage = $"Found {Listings.Count} game{(Listings.Count == 1 ? "" : "s")} in {response.DurationMs} ms{demoBadge}"; + SelectedListing = Listings.FirstOrDefault(); + } + } + else + { + HasSearched = true; + ErrorMessage = response.ErrorMessage ?? "Search request failed."; + ErrorDetail = response.ErrorDetail; + StatusMessage = $"Error ({response.StatusCode}): {response.ErrorMessage}"; + } + } + catch (OperationCanceledException) + { + StatusMessage = "Search cancelled."; + } + catch (Exception ex) + { + ErrorMessage = "An unexpected error occurred."; + ErrorDetail = ex.Message; + StatusMessage = "Search failed."; + } + finally + { + IsSearching = false; + } + } + + public void CancelPendingOperations() + { + var cts = Interlocked.Exchange(ref _searchCts, null); + try { cts?.Cancel(); } catch { } + try { cts?.Dispose(); } catch { } + } + + [RelayCommand] + public void ClearSearch() + { + CancelPendingOperations(); + SearchQuery = string.Empty; + ErrorMessage = null; + ErrorDetail = null; + StatusMessage = null; + Listings.Clear(); + SelectedListing = null; + HasSearched = false; + CopiedFeedbackMessage = null; + } + + [RelayCommand] + public void SelectListing(SteamGameListing? listing) + { + SelectedListing = listing; + CopiedFeedbackMessage = null; + } + + [RelayCommand] + public void CloseDetails() + { + SelectedListing = null; + } + + [RelayCommand] + public async Task OpenInSteamAsync(SteamGameListing? listing) + { + var target = listing ?? SelectedListing; + if (target != null && !string.IsNullOrWhiteSpace(target.AppId)) + { + var uri = new Uri($"steam://store/{target.AppId}"); + await _launcherService.LaunchUriAsync(uri); + } + } + + [RelayCommand] + public async Task OpenStoreLinkAsync(SteamGameListing? listing) + { + var target = listing ?? SelectedListing; + if (target != null && Uri.TryCreate(target.StoreLink, UriKind.Absolute, out var uri)) + { + await _launcherService.LaunchUriAsync(uri); + } + } + + [RelayCommand] + public async Task CopyAppIdAsync(SteamGameListing? listing) + { + var target = listing ?? SelectedListing; + if (target != null && !string.IsNullOrWhiteSpace(target.AppId)) + { + await _clipboardService.SetTextAsync(target.AppId); + ShowCopiedFeedback($"Copied App ID: {target.AppId}"); + } + } + + [RelayCommand] + public async Task CopyStoreLinkAsync(SteamGameListing? listing) + { + var target = listing ?? SelectedListing; + if (target != null && !string.IsNullOrWhiteSpace(target.StoreLink)) + { + await _clipboardService.SetTextAsync(target.StoreLink); + ShowCopiedFeedback("Copied Steam Store Link"); + } + } + + public void UpdateDemoMode(bool isDemo) + { + IsDemoMode = isDemo; + if (HasSearched && !string.IsNullOrWhiteSpace(SearchQuery)) + { + // Re-run search under new mode + _ = SearchAsync(); + } + } + + private void ShowCopiedFeedback(string message) + { + CopiedFeedbackMessage = message; + } +} diff --git a/desktop/SteamAPI.Desktop/ViewModels/MainWindowViewModel.cs b/desktop/SteamAPI.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..93ade50 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,152 @@ +using Avalonia; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SteamAPI.Desktop.Services; + +namespace SteamAPI.Desktop.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly ISettingsService _settingsService; + private readonly IApiProcessManager _processManager; + + [ObservableProperty] + private int _selectedTabIndex; + + [ObservableProperty] + private bool _isDemoMode; + + [ObservableProperty] + private string _apiStatusText = "Checking..."; + + public GameExplorerViewModel GameExplorer { get; } + public ApiInspectorViewModel ApiInspector { get; } + public SettingsViewModel Settings { get; } + public AboutViewModel About { get; } + + public ViewModelBase CurrentView => SelectedTabIndex switch + { + 0 => GameExplorer, + 1 => ApiInspector, + 2 => Settings, + 3 => About, + _ => GameExplorer + }; + + public MainWindowViewModel( + GameExplorerViewModel gameExplorer, + ApiInspectorViewModel apiInspector, + SettingsViewModel settings, + AboutViewModel about, + ISettingsService settingsService, + IApiProcessManager processManager) + { + GameExplorer = gameExplorer; + ApiInspector = apiInspector; + Settings = settings; + About = about; + _settingsService = settingsService; + _processManager = processManager; + + _isDemoMode = _settingsService.CurrentSettings.DemoMode; + UpdateApiStatus(); + + Settings.OnDemoModeToggled += HandleDemoModeToggled; + _processManager.OnStateChanged += HandleProcessStateChanged; + } + + private void HandleProcessStateChanged(ApiProcessState _) => UpdateApiStatus(); + + public bool IsExplorerActive => SelectedTabIndex == 0; + public bool IsInspectorActive => SelectedTabIndex == 1; + public bool IsSettingsActive => SelectedTabIndex == 2; + public bool IsAboutActive => SelectedTabIndex == 3; + + partial void OnSelectedTabIndexChanged(int value) + { + OnPropertyChanged(nameof(CurrentView)); + OnPropertyChanged(nameof(IsExplorerActive)); + OnPropertyChanged(nameof(IsInspectorActive)); + OnPropertyChanged(nameof(IsSettingsActive)); + OnPropertyChanged(nameof(IsAboutActive)); + } + + [RelayCommand] + public void SelectTab(object? parameter) + { + int index = parameter switch + { + int i => i, + string s when int.TryParse(s, out var parsed) => parsed, + _ => -1 + }; + + if (index >= 0 && index <= 3) + { + SelectedTabIndex = index; + } + } + + private void HandleDemoModeToggled(bool isDemo) + { + IsDemoMode = isDemo; + GameExplorer.UpdateDemoMode(isDemo); + UpdateApiStatus(); + } + + private bool _isShuttingDown; + + public void UpdateApiStatus() + { + if (_isShuttingDown) + { + return; + } + + if (Application.Current != null && !Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(UpdateApiStatusCore); + return; + } + + UpdateApiStatusCore(); + } + + private void UpdateApiStatusCore() + { + if (_isShuttingDown) + { + return; + } + + if (IsDemoMode) + { + ApiStatusText = "Offline Demo Mode"; + return; + } + + ApiStatusText = _processManager.State switch + { + ApiProcessState.Running => $"Loopback API Active ({_processManager.BoundUrl})", + ApiProcessState.Starting => "Starting Loopback API...", + ApiProcessState.Failed => "Loopback API Failed", + _ => $"External API: {_settingsService.CurrentSettings.ApiBaseUrl}" + }; + } + + public async Task ShutdownAsync() + { + if (_isShuttingDown) + { + return; + } + _isShuttingDown = true; + + _processManager.OnStateChanged -= HandleProcessStateChanged; + Settings.OnDemoModeToggled -= HandleDemoModeToggled; + Settings.DetachEvents(); + GameExplorer.CancelPendingOperations(); + await _processManager.StopAsync().ConfigureAwait(false); + } +} diff --git a/desktop/SteamAPI.Desktop/ViewModels/SettingsViewModel.cs b/desktop/SteamAPI.Desktop/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..f1d3907 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewModels/SettingsViewModel.cs @@ -0,0 +1,186 @@ +using Avalonia; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SteamAPI.Desktop.Models; +using SteamAPI.Desktop.Services; + +namespace SteamAPI.Desktop.ViewModels; + +public partial class SettingsViewModel : ViewModelBase +{ + private readonly ISettingsService _settingsService; + private readonly ISteamApiClient _apiClient; + private readonly IApiProcessManager _processManager; + + [ObservableProperty] + private string _apiBaseUrl = "http://localhost:5271"; + + [ObservableProperty] + private int _timeoutSeconds = 30; + + [ObservableProperty] + private bool _isDemoMode; + + [ObservableProperty] + private bool _autoStartApi; + + [ObservableProperty] + private bool _isTestingConnection; + + [ObservableProperty] + private string? _connectionStatusMessage; + + [ObservableProperty] + private bool? _isConnectionSuccessful; + + [ObservableProperty] + private string _apiProcessStateText = "Stopped"; + + [ObservableProperty] + private string? _apiProcessError; + + [ObservableProperty] + private string? _saveFeedbackMessage; + + public event Action? OnDemoModeToggled; + + public SettingsViewModel( + ISettingsService settingsService, + ISteamApiClient apiClient, + IApiProcessManager processManager) + { + _settingsService = settingsService; + _apiClient = apiClient; + _processManager = processManager; + + LoadSettings(); + _processManager.OnStateChanged += UpdateProcessState; + UpdateProcessState(_processManager.State); + } + + private void LoadSettings() + { + var current = _settingsService.CurrentSettings; + ApiBaseUrl = current.ApiBaseUrl; + TimeoutSeconds = current.TimeoutSeconds; + IsDemoMode = current.DemoMode; + AutoStartApi = current.AutoStartApi; + + _apiClient.BaseUrl = ApiBaseUrl; + _apiClient.TimeoutSeconds = TimeoutSeconds; + } + + [RelayCommand] + public void SaveSettings() + { + var settings = new AppSettings + { + ApiBaseUrl = ApiBaseUrl.Trim(), + TimeoutSeconds = TimeoutSeconds, + DemoMode = IsDemoMode, + AutoStartApi = AutoStartApi + }; + + _settingsService.SaveSettings(settings); + _apiClient.BaseUrl = settings.ApiBaseUrl; + _apiClient.TimeoutSeconds = settings.TimeoutSeconds; + + OnDemoModeToggled?.Invoke(IsDemoMode); + SaveFeedbackMessage = "Settings saved successfully."; + } + + [RelayCommand] + public void ResetDefaults() + { + _settingsService.ResetToDefaults(); + LoadSettings(); + OnDemoModeToggled?.Invoke(IsDemoMode); + SaveFeedbackMessage = "Settings reset to defaults."; + } + + [RelayCommand] + public async Task TestConnectionAsync() + { + IsTestingConnection = true; + ConnectionStatusMessage = "Testing connection to " + ApiBaseUrl + "..."; + IsConnectionSuccessful = null; + + try + { + _apiClient.BaseUrl = ApiBaseUrl.Trim(); + var response = await _apiClient.CheckConnectionAsync(); + + if (response.IsSuccess) + { + IsConnectionSuccessful = true; + ConnectionStatusMessage = $"Connection successful! Responded in {response.DurationMs} ms (HTTP {response.StatusCode})."; + } + else + { + IsConnectionSuccessful = false; + ConnectionStatusMessage = $"Connection failed: {response.ErrorMessage ?? "Unreachable"}"; + } + } + catch (Exception ex) + { + IsConnectionSuccessful = false; + ConnectionStatusMessage = $"Error: {ex.Message}"; + } + finally + { + IsTestingConnection = false; + } + } + + [RelayCommand] + public async Task StartApiProcessAsync() + { + ApiProcessError = null; + var success = await _processManager.StartAsync(); + if (!success) + { + ApiProcessError = _processManager.LastError; + } + else + { + ApiBaseUrl = _processManager.BoundUrl; + SaveSettings(); + } + } + + [RelayCommand] + public async Task StopApiProcessAsync() + { + await _processManager.StopAsync(); + } + + public void DetachEvents() + { + _processManager.OnStateChanged -= UpdateProcessState; + } + + private void UpdateProcessState(ApiProcessState state) + { + if (Application.Current != null && !Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => ApplyProcessState(state)); + return; + } + + ApplyProcessState(state); + } + + private void ApplyProcessState(ApiProcessState state) + { + ApiProcessStateText = state switch + { + ApiProcessState.Stopped => "Stopped", + ApiProcessState.Starting => "Starting...", + ApiProcessState.Running => $"Running (PID {_processManager.ProcessId})", + ApiProcessState.Failed => "Failed to start", + _ => "Unknown" + }; + ApiProcessError = _processManager.LastError; + } +} diff --git a/desktop/SteamAPI.Desktop/ViewModels/ViewModelBase.cs b/desktop/SteamAPI.Desktop/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..ee2fa25 --- /dev/null +++ b/desktop/SteamAPI.Desktop/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SteamAPI.Desktop.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/desktop/SteamAPI.Desktop/Views/AboutView.axaml b/desktop/SteamAPI.Desktop/Views/AboutView.axaml new file mode 100644 index 0000000..e090f98 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Views/AboutView.axaml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/SteamAPI.Desktop/Views/MainWindow.axaml.cs b/desktop/SteamAPI.Desktop/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..08ae2cb --- /dev/null +++ b/desktop/SteamAPI.Desktop/Views/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace SteamAPI.Desktop.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/desktop/SteamAPI.Desktop/Views/SettingsView.axaml b/desktop/SteamAPI.Desktop/Views/SettingsView.axaml new file mode 100644 index 0000000..53600f5 --- /dev/null +++ b/desktop/SteamAPI.Desktop/Views/SettingsView.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +