diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7a07e171..0def28a1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -30,7 +30,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: 7.0.x
+ dotnet-version: 8.0.x
- id: cache-restore
uses: actions/cache/restore@v4
with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4606db1a..ebf5488f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,14 +1,62 @@
## 0.6.0
+### 1.0.0-alpha
+
+#### General
+
+- Upgraded to .NET 8
+- Updated [WPF-UI](https://github.com/lepoco/wpfui) to version 3.0.4
+
+#### SAM
+
+- Added status bar item for favorites
+- Added ability to show hidden apps
+- Added ability to unhide all apps
+- Added ability to show only favorited apps
+- Added autocomplete to search
+- Added overlay for favorited apps
+- Added overlay for hidden apps
+- Added overlay menu for quickly adding/removing favorites and hiding/showing apps
+- Added `View on SteamGridDB` menu option
+- Added support for displaying [Steam Grid](https://www.steamgriddb.com/) assets
+ - **Note:** Animated images are not currently supported
+- Added support for loading Steam's `librarycache` images
+- Implemented app refresh queue
+ - This should help users with larger Steam libraries from having issues with the Steamworks Web API
+- **[BUG]** Fixed error in debug output when launching a web page
+- **[BUG]** Fixed hidden apps status bar item count not updating
+- **[BUG]** Fixed library search textbox alignment
+
+#### SAM.Manager
+
+- **[BUG]** Ability to filter achievements using the drop down has been temporarily removed
+
+#### Planned
+
+##### SAM
+
+- Ability to customize favorites overlay color
+- Ability to customize hidden overlay color
+- Basic command line options
+ - For automating resettings stats/achievements, generating reports, etc.
+- Save and auto load `Show Hidden` and `Show Only Favorites` view settings
+
+#### Known Issues
+
+- An `OutOfMemoryException` will be thrown when attempting to load an animated Grid image
+ - This should only be visible in the output when debugging and can be ignored
+- Startup SlpashScreen's TitleBar was removed
+ - During startup, you will not be able to move the SplashScreen or close it using the Window buttons
+
### 0.6.0-alpha
- Updated logging
-- [BUG] Removed library caching
+- **[BUG]** Removed library caching
## 0.5.0
-- [BUG] Fixed issue with increment only stats allowing invalid values
-- [BUG] Fixed issue with increment only stats not displaying properly
+- **[BUG]** Fixed issue with increment only stats allowing invalid values
+- **[BUG]** Fixed issue with increment only stats not displaying properly
## 0.4.0
@@ -32,7 +80,7 @@
- Added new release workflow
- Added ability to Refresh Steam library
- Switched ViewModels to source generators
-- [BUG] Fixed issue with the "Show Hidden" button not working for Achievements
+- **[BUG]** Fixed issue with the "Show Hidden" button not working for Achievements
## 0.1.0
diff --git a/README.md b/README.md
index 96b94677..e35fdc24 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,27 @@
-
+
+
+
-
+
## Overview
-The Steam Achievement Manager lets you manage achievements, stats, and more for any currently supported app.
+The Steam Achievement Manager lets you manage achievements, stats, and more for any currently supported Steam app.
-
-
+
+
-This project is a fork of the [Steam Achievement Manager](https://github.com/gibbed/SteamAchievementManager) project with the goal of updating to .NET Core and WPF. This is very much a work in progress.
+This project is a fork of the [Steam Achievement Manager](https://github.com/gibbed/SteamAchievementManager) project with the goal of updating to .NET Core and WPF.
## Project Structure
@@ -63,7 +65,7 @@ flowchart TB
Legacy Project
- New Project
+ New Project
Description
@@ -88,33 +90,26 @@ flowchart TB
----
-
-## FAQ
-
-### What is an App or App ID?
-
-> An Application (or App) is the main representation of a product on Steam. An App generally has its own store page, it's own Community Hub, and is what appears in customers' libraries. Each App is represented by a unique ID called an App ID - that you'll see referenced throughout this documentation and used with the Steamworks API and Steamworks Web API. Generally a single product will not span multiple Applications. ([source](https://partner.steamgames.com/doc/store/application))
+## Sponsors
-### Why does SAM let people cheat achievements?
+
+
+
-Some games have achievements that are no longer reasonably or actually attainable. While SAM _can_ be used to abuse the achievement system, it also lets people who do care about achievements unlock achievements that would otherwise be impossible. One common example is achievements requiring you to play multiplayer on a game that no longer has any active players, or even dedicated servers. **SAM** is a potential solution for a game's poorly designed achievements.
+A special thank you to [JetBrains](https://www.jetbrains.com/) for their continued [Support of Open-Source Projects](https://www.jetbrains.com/community/opensource/#support) like **SAM**.
----
+> [!NOTE]
+> Active **SAM** contributors are eligible to receieve complimentary licenses [for non-commercial development] for **all** **JetBrains** products. For questions regarding eligability please refer to the [Open Source FAQ](https://sales.jetbrains.com/hc/en-gb/categories/13706169183250-Free-Licenses-for-OSS-development).
## Acknowledgements
-- [Devexpress MVVM](https://github.com/DevExpress/DevExpress.Mvvm.Free)
-- [Font-Awesome-WPF](https://github.com/charri/Font-Awesome-WPF)
-- [SteamCountries](https://github.com/RudeySH/SteamCountries)
-- [WPF UI](https://github.com/lepoco/wpfui)
-
----
+
+ DevExpress MVVM • SteamCountries • WPF UI
+
## Resources
- [DevExpress MVVM](https://docs.devexpress.com/WPF/15112/mvvm-framework)
-- [Font-Awesome-WPF Documentation](https://github.com/charri/Font-Awesome-WPF/blob/master/README-WPF.md)
-- [Steamworks API Overview](https://partner.steamgames.com/doc/sdk/api)
+- [Steamworks Overview](https://partner.steamgames.com/doc/sdk/api)
- [Steamworks API](https://partner.steamgames.com/doc/api)
- [Steamworks Web API](https://partner.steamgames.com/doc/webapi)
diff --git a/resources/SAM_API_logo_default.svg b/resources/SAM_API_logo_default.svg
new file mode 100644
index 00000000..57d5d15f
--- /dev/null
+++ b/resources/SAM_API_logo_default.svg
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Achievement Manager API
+
+
diff --git a/resources/SAM_logo_default.svg b/resources/SAM_logo_default.svg
new file mode 100644
index 00000000..f47719b6
--- /dev/null
+++ b/resources/SAM_logo_default.svg
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Achievement Manager
+
+
diff --git a/resources/animated/SAM.webp b/resources/animated/SAM.webp
new file mode 100644
index 00000000..8c72d728
Binary files /dev/null and b/resources/animated/SAM.webp differ
diff --git a/resources/ico/sam.ico b/resources/ico/sam.ico
new file mode 100644
index 00000000..a7dcf66d
Binary files /dev/null and b/resources/ico/sam.ico differ
diff --git a/resources/1200px-JetBrains_Logo_2016.svg.png b/resources/ref/JetBrains_Logo_2016.png
similarity index 100%
rename from resources/1200px-JetBrains_Logo_2016.svg.png
rename to resources/ref/JetBrains_Logo_2016.png
diff --git a/resources/JetBrains_Logo_2016.svg b/resources/ref/JetBrains_Logo_2016.svg
similarity index 100%
rename from resources/JetBrains_Logo_2016.svg
rename to resources/ref/JetBrains_Logo_2016.svg
diff --git a/resources/ref/devexpress.png b/resources/ref/devexpress.png
new file mode 100644
index 00000000..849bf377
Binary files /dev/null and b/resources/ref/devexpress.png differ
diff --git a/resources/ref/dotnet.svg b/resources/ref/dotnet.svg
new file mode 100644
index 00000000..d204a090
--- /dev/null
+++ b/resources/ref/dotnet.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/resources/ref/github-actions.svg b/resources/ref/github-actions.svg
new file mode 100644
index 00000000..3d0c5a3b
--- /dev/null
+++ b/resources/ref/github-actions.svg
@@ -0,0 +1,10 @@
+
+
+ GitHub Actions
+
+
+
+
+
+
+
diff --git a/resources/ref/resharper_full_logo.png b/resources/ref/resharper_full_logo.png
new file mode 100644
index 00000000..cb21466d
Binary files /dev/null and b/resources/ref/resharper_full_logo.png differ
diff --git a/resources/resharper_logo_300x300.png b/resources/ref/resharper_logo.png
similarity index 100%
rename from resources/resharper_logo_300x300.png
rename to resources/ref/resharper_logo.png
diff --git a/resources/ref/resharper_logo_s.png b/resources/ref/resharper_logo_s.png
new file mode 100644
index 00000000..cd72e688
Binary files /dev/null and b/resources/ref/resharper_logo_s.png differ
diff --git a/resources/ref/vs2022.svg b/resources/ref/vs2022.svg
new file mode 100644
index 00000000..973cc689
--- /dev/null
+++ b/resources/ref/vs2022.svg
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/sam_icon_logo.png b/resources/sam_icon_logo.png
new file mode 100644
index 00000000..ed62e161
Binary files /dev/null and b/resources/sam_icon_logo.png differ
diff --git a/resources/sam_icon_logo.svg b/resources/sam_icon_logo.svg
new file mode 100644
index 00000000..071c9ba3
--- /dev/null
+++ b/resources/sam_icon_logo.svg
@@ -0,0 +1 @@
+
diff --git a/resources/sam_screenshot.png b/resources/sam_screenshot.png
deleted file mode 100644
index 9ca73f32..00000000
Binary files a/resources/sam_screenshot.png and /dev/null differ
diff --git a/resources/sam_screenshot_2.png b/resources/sam_screenshot_2.png
deleted file mode 100644
index e69ae9d6..00000000
Binary files a/resources/sam_screenshot_2.png and /dev/null differ
diff --git a/resources/screenshots/SAM.png b/resources/screenshots/SAM.png
index 15269ed8..39fe709f 100644
Binary files a/resources/screenshots/SAM.png and b/resources/screenshots/SAM.png differ
diff --git a/resources/screenshots/SAM_2.png b/resources/screenshots/SAM_2.png
deleted file mode 100644
index ac35198f..00000000
Binary files a/resources/screenshots/SAM_2.png and /dev/null differ
diff --git a/src/SAM.API/SAM.API.csproj b/src/SAM.API/SAM.API.csproj
index 29965800..4253db63 100644
--- a/src/SAM.API/SAM.API.csproj
+++ b/src/SAM.API/SAM.API.csproj
@@ -1,7 +1,7 @@
- net7.0-windows
+ net8.0-windows
preview
x86
SAM API
@@ -37,14 +37,14 @@
-
-
+
+
all
-
+
diff --git a/src/SAM.API/Steam.cs b/src/SAM.API/Steam.cs
index 8b50a153..e387e195 100644
--- a/src/SAM.API/Steam.cs
+++ b/src/SAM.API/Steam.cs
@@ -94,6 +94,7 @@ public static bool Load()
return true;
}
+ // TODO: replace with CSWin32's PInvoke
private struct Native
{
[DllImport("kernel32.dll", SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)]
diff --git a/src/SAM.Core/API/Steam/SteamClientManager.cs b/src/SAM.Core/API/Steam/SteamClientManager.cs
index 0c0e5658..3b5902c7 100644
--- a/src/SAM.Core/API/Steam/SteamClientManager.cs
+++ b/src/SAM.Core/API/Steam/SteamClientManager.cs
@@ -1,5 +1,10 @@
using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.IO;
+using System.Linq;
using System.Reflection;
+using System.Windows.Documents;
using log4net;
using SAM.API;
@@ -7,14 +12,63 @@ namespace SAM.Core
{
public static class SteamClientManager
{
+ private record SteamAppImageCacheKey(uint id, SteamImageType type);
+
private static readonly ILog log = LogManager.GetLogger(MethodBase.GetCurrentMethod()!.DeclaringType);
-
+
+ private static readonly object syncLock = new ();
+ private static readonly List gridCacheAppWarnings = [];
+ private static readonly Dictionary cachedAppImagePaths = [];
private static bool _isInitialized;
-
+ private static string _steamInstallPath;
+ private static string _cachePath;
+ private static string _gridCachePath;
+
public static uint AppId { get; private set; }
public static string CurrentLanguage { get; private set; }
+
+ public static string CachePath
+ {
+ get
+ {
+ if (_cachePath != null) return _cachePath;
+ lock (syncLock)
+ {
+ _cachePath = Path.Join(SteamInstallPath, @"appcache", @"librarycache");
+ }
+ return _cachePath;
+ }
+ }
+
+ public static string GridCachePath
+ {
+ get
+ {
+ if (_gridCachePath != null) return _gridCachePath;
+ lock (syncLock)
+ {
+ var userId = SteamUserManager.GetActiveUser();
+
+ _gridCachePath = Path.Join(SteamInstallPath, @"userdata", $"{userId}", @"config", @"grid");
+ }
+ return _gridCachePath;
+ }
+ }
+
public static Client Default { get; private set; }
+ public static string SteamInstallPath
+ {
+ get
+ {
+ if (_steamInstallPath != null) return _steamInstallPath;
+
+ _steamInstallPath = Steam.GetInstallPath();
+
+ return _steamInstallPath;
+ }
+ }
+
public static void Init(uint appId)
{
if (_isInitialized) throw new SAMInitializationException($"The Steam {nameof(Client)} has already been initialized.");
@@ -38,5 +92,145 @@ public static void Init(uint appId)
}
}
+ // TODO: load user's grid images (if present)
+ public static bool TryGetCachedAppImage(uint appId, SteamImageType type, out Image image)
+ {
+ try
+ {
+ if (!CachedAppImageExists(appId, type))
+ {
+ image = null;
+ return false;
+ }
+
+ var imagePath = GetCachedAppImagePath(appId, type);
+
+ image = Image.FromFile(imagePath);
+
+ return true;
+ }
+ catch
+ {
+ image = null;
+ return false;
+ }
+ }
+
+ public static bool CachedAppImageExists(uint appId, SteamImageType type)
+ {
+ try
+ {
+ var imagePath = GetCachedAppImagePath(appId, type);
+
+ return File.Exists(imagePath);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public static Image GetCachedAppImage(uint appId, SteamImageType type)
+ {
+ string imagePath = null;
+
+ try
+ {
+ if (!CachedAppImageExists(appId, type)) throw new FileNotFoundException($"A cached {type} image for app '{appId}' was not found.");
+
+ imagePath = GetCachedAppImagePath(appId, type);
+
+ return Image.FromFile(imagePath);
+ }
+
+ // images that aren't supported will throw an OutOfMemoryException
+ catch (OutOfMemoryException oom)
+ {
+ var message = $"Failed to load {type:G} for app {appId} from '{imagePath}'.";
+
+ throw new SAMException(message, oom);
+ }
+ catch (FileNotFoundException) { throw; }
+ catch (Exception e)
+ {
+ var message = $"An error occurred attempting to load the {type:G} for app {appId}. {e.Message}";
+
+ throw new SAMException(message, e);
+ }
+ }
+
+ private static string GetCachedAppImagePath(uint appId, SteamImageType type)
+ {
+ var fileName = GetAppImageName(appId, type);
+
+ // if this is a grid icon the extension can vary, so search for a match
+ if (SteamImageType.Grid.HasFlag(type))
+ {
+ // first check and see if we've already cached a valid image for this app and type
+ var key = new SteamAppImageCacheKey(appId, type);
+
+ if (cachedAppImagePaths.TryGetValue(key, out var path))
+ {
+ return path;
+ }
+
+ // search for a matching file name with any extension
+ var searchPattern = $"{fileName}.*";
+
+ var results = Directory.GetFiles(GridCachePath, searchPattern);
+
+ // TODO: this will show multiple warnings for the same file
+ // if they have multiple files saved for the app (with different extensions), just use
+ // the first result after logging a warning
+ if (results.Length > 1)
+ {
+ var warnedPreviously = gridCacheAppWarnings.Contains(appId);
+
+ // first time warning for this app id
+ if (!warnedPreviously)
+ {
+ log.Warn($"Found {results.Length} {type:G} images for app {appId}.");
+
+ gridCacheAppWarnings.Add(appId);
+ }
+ }
+
+ var result = results.FirstOrDefault();
+ if (result == null)
+ {
+ return null;
+ }
+
+ var gridFileName = Path.GetFileName(result);
+
+ // cache the result so that we don't have to calculate it again
+ cachedAppImagePaths[key] = result;
+
+ log.Info($"Found {SteamImageType.Grid} image '{gridFileName}' for app {appId}.");
+
+ return result;
+ }
+
+ var imagePath = Path.Join(CachePath, fileName);
+
+ return imagePath;
+ }
+
+ private static string GetAppImageName(uint appId, SteamImageType type)
+ {
+ return type switch
+ {
+ SteamImageType.GridLandscape => $"{appId}",
+ SteamImageType.GridPortrait => $"{appId}p",
+ SteamImageType.GridIcon => $"{appId}_icon",
+ SteamImageType.GridHero => $"{appId}_hero",
+ SteamImageType.Header => $"{appId}_header.jpg",
+ SteamImageType.Icon => $"{appId}_icon.jpg",
+ SteamImageType.Logo => $"{appId}_logo.jpg",
+ SteamImageType.LibraryHero => $"{appId}_library_hero.jpg",
+ SteamImageType.LibraryHeroBlur => $"{appId}_library_hero_blur.jpg",
+ _ => throw new NotSupportedException($"{type} is not available in the local cache. Use the {nameof(SteamCdnHelper)} instead.")
+ };
+ }
}
}
diff --git a/src/SAM.Core/API/Steam/SteamUserManager.cs b/src/SAM.Core/API/Steam/SteamUserManager.cs
new file mode 100644
index 00000000..452cf1e6
--- /dev/null
+++ b/src/SAM.Core/API/Steam/SteamUserManager.cs
@@ -0,0 +1,32 @@
+using Microsoft.Win32;
+
+namespace SAM.Core;
+
+public static class SteamUserManager
+{
+ private const string STEAM_ACTIVE_PROCESS_REG_PATH = @"Software\Valve\Steam\ActiveProcess";
+ private const string STEAM_ACTIVE_USER_ENTRY_NAME = @"ActiveUser";
+
+ private static int? _activeUser;
+
+ public static int GetActiveUser()
+ {
+ if (_activeUser.HasValue) return _activeUser.Value;
+
+ // TODO: consider switching this to HKCU:\SOFTWARE\Valve\Steam\ActiveProcess\SteamClientDll
+ using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32);
+ using var key = baseKey.OpenSubKey(STEAM_ACTIVE_PROCESS_REG_PATH);
+
+ var value = key?.GetValue(STEAM_ACTIVE_USER_ENTRY_NAME);
+
+ if (value == null)
+ {
+ var message = $"Unable to determine Steam's current {STEAM_ACTIVE_USER_ENTRY_NAME}.";
+ throw new SAMException(message);
+ }
+
+ _activeUser = (int) value;
+
+ return _activeUser.Value;
+ }
+}
diff --git a/src/SAM.Core/API/Steamworks/SAMLibraryHelper.cs b/src/SAM.Core/API/Steamworks/SAMLibraryHelper.cs
index 2e67ece8..6e7d0142 100644
--- a/src/SAM.Core/API/Steamworks/SAMLibraryHelper.cs
+++ b/src/SAM.Core/API/Steamworks/SAMLibraryHelper.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
-using System.IO;
using System.Linq;
using System.Net.Http;
using System.Xml.XPath;
@@ -16,7 +15,7 @@ public static class SAMLibraryHelper
private static readonly ILog log = LogManager.GetLogger(nameof(SAMLibraryHelper));
- // TODO: Store this somewhere outside of the application so that it can be actively maintained separately
+ // TODO: Store this somewhere outside the application so that it can be actively maintained separately
private static readonly uint[] _ignoredApps =
{
13260 // Unreal Development Kit
diff --git a/src/SAM.Core/API/Steamworks/SteamworksManager.cs b/src/SAM.Core/API/Steamworks/SteamworksManager.cs
index 4bc2694c..2d2b82a3 100644
--- a/src/SAM.Core/API/Steamworks/SteamworksManager.cs
+++ b/src/SAM.Core/API/Steamworks/SteamworksManager.cs
@@ -1,9 +1,13 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.ComponentModel;
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Text;
using System.Text.Json;
+using System.Threading;
using System.Threading.Tasks;
using log4net;
using Newtonsoft.Json;
@@ -19,7 +23,12 @@ public static class SteamworksManager
private static readonly ILog log = LogManager.GetLogger(nameof(SteamworksManager));
+ private static readonly object syncLock = new ();
+ // ReSharper disable once InconsistentNaming
private static readonly HttpClient _client = new ();
+ // ReSharper disable once InconsistentNaming
+ private static readonly ConcurrentQueue _refreshQueue = new ();
+ private static BackgroundWorker _refreshWorker;
public static Dictionary GetAppList()
{
@@ -216,28 +225,178 @@ public static async Task GetAppInfoAsync(uint id, bool loadDlc =
}
}
+ ///
+ /// Queues a for loading information from the Steam store.
+ ///
+ ///
+ /// Occurs when is
+ public static void LoadStoreInfo(SteamApp app)
+ {
+ if (app == null) throw new ArgumentNullException(nameof(app));
+
+ // if we are going to skip it anyway, don't bother queueing it
+ if (ShouldSkip(app.Id))
+ {
+ return;
+ }
+
+ _refreshQueue.Enqueue(app);
+
+ // if we've already started the background worker then return
+ if (_refreshWorker != null) return;
+
+ lock (syncLock)
+ {
+ // re-checking after entering lock
+ if (_refreshWorker != null) return;
+
+ _refreshWorker = new ();
+ _refreshWorker.WorkerSupportsCancellation = true;
+ _refreshWorker.DoWork += OnRefreshWorkerDoWork;
+ _refreshWorker.RunWorkerCompleted += OnRefreshWorkerCompleted;
+
+ _refreshWorker.RunWorkerAsync();
+ }
+ }
+
+ private static void OnRefreshWorkerCompleted(object sender, RunWorkerCompletedEventArgs args)
+ {
+ if (args.Cancelled)
+ {
+ log.Warn($"{nameof(SteamworksManager)} refresh {nameof(BackgroundWorker)} stopped due to requested cancellation.");
+ }
+
+ log.Info($"The {nameof(SteamworksManager)} refresh {nameof(BackgroundWorker)} stopped.");
+ }
+
+ private static void OnRefreshWorkerDoWork(object sender, DoWorkEventArgs args)
+ {
+ try
+ {
+ while (true)
+ {
+ if (_refreshWorker.CancellationPending)
+ {
+ args.Cancel = true;
+ return;
+ }
+
+ while (_refreshQueue.TryDequeue(out var app))
+ {
+ // take the next app in the queue and load the store info, if successful this
+ // will automatically load its images
+ var result = LoadAppInfo(app);
+
+ if (result != SteamworksOperationResult.Success)
+ {
+ // re-queue the app to try again later
+ _refreshQueue.Enqueue(app);
+
+ continue;
+ }
+
+ log.Debug($"Completed store information refresh for {app.Name} ({app.Id}).");
+ }
+
+ // once we're done with everything in the queue, wait 10 seconds before checking it again
+ // in case new items are added
+ Thread.Sleep(TimeSpan.FromSeconds(10));
+ }
+ }
+ catch (Exception e)
+ {
+ var message = $"An error occurred while refreshing app information. {e.Message}";
+ log.Error(message, e);
+
+ args.Cancel = true;
+ }
+ }
+
+ private static SteamworksOperationResult LoadAppInfo(SteamApp app)
+ {
+ const int MAX_RETRIES = 3;
+ var retries = 0;
+ var rateLimited = false;
+
+ do
+ {
+ // this function processes one individual app from the queue, including any
+ // retries (up to the maximum allotted)
+ try
+ {
+ var storeInfo = GetAppInfo(app.Id);
+
+ // this happens when an app is explicitly skipped (_skipStoreInfoAppIds)
+ if (storeInfo == null)
+ {
+ continue;
+ }
+
+ app.StoreInfo = storeInfo;
+
+ return SteamworksOperationResult.Success;
+ }
+ catch (HttpRequestException hre) when (hre.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests)
+ {
+ rateLimited = true;
+
+ // if we are being blocked (Unauthorized or Forbidden) then wait at least 30 minutes
+ // otherwise if it's an intermittent failure or TooManyRequests then wait 5 minutes
+ var retryTime = hre.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
+ ? TimeSpan.FromMinutes(30)
+ : TimeSpan.FromMinutes(5);
+
+ var retrySb = new StringBuilder();
+
+ retrySb.Append($"Request for store info on app '{app.Id}' returned {nameof(HttpStatusCode)} {hre.StatusCode:D} ({hre.StatusCode:G}). ");
+ retrySb.Append($"Waiting {retryTime.TotalMinutes:N0} minute(s) and then retrying...");
+
+ log.Warn(retrySb);
+
+ Thread.Sleep(retryTime);
+ }
+ catch (Exception e)
+ {
+ log.Error($"An error occurred attempting to load the store info for {app.Name} ({app.Id}). {e.Message}", e);
+ break;
+ }
+ finally
+ {
+ retries++;
+ }
+ }
+ while (retries < MAX_RETRIES);
+
+ return rateLimited
+ ? SteamworksOperationResult.RateLimited
+ : SteamworksOperationResult.Failed;
+ }
+
private static bool ShouldSkip(uint id)
{
return _skipStoreInfoAppIds.Contains(id);
}
+ // ReSharper disable CommentTypo
// TODO: Create a better way to skip non-queryable apps in the store API
- // these are all app ids that do not return successfully so we can skip them
+ // these are all app ids that do not return successfully, so we can skip them
// and reduce calls to the store API
+ // ReSharper disable once InconsistentNaming
private static readonly uint[] _skipStoreInfoAppIds =
- {
- 41010, // Serious Sam HD: The Second Encounter
- 42160, // War of the Roses
- 91310, // Dead Island
- 92500, // PC Gamer
- 200110, // Nosgoth
- 202270, // Leviathan: Warships
- 204080, // The Showdown Effect
- 218130, // Dungeonland
- 223390, // Forge
- 225140, // Duke Nukem 3D: Megaton Edition
- 254270, // Dungeonland - All access pass
- 321040 // DiRT 3 Complete Edition
- };
+ [
+ //41010, // Serious Sam HD: The Second Encounter
+ //42160, // War of the Roses
+ //91310, // Dead Island
+ //92500, // PC Gamer
+ //200110, // Nosgoth
+ //202270, // Leviathan: Warships
+ //204080, // The Showdown Effect
+ //218130, // Dungeonland
+ //223390, // Forge
+ //225140, // Duke Nukem 3D: Megaton Edition
+ //254270, // Dungeonland - All access pass
+ //321040 // DiRT 3 Complete Edition
+ ];
+ // ReSharper restore CommentTypo
}
}
diff --git a/src/SAM.Core/Common/Behaviors/ContextMenuBehavior.cs b/src/SAM.Core/Common/Behaviors/ContextMenuBehavior.cs
new file mode 100644
index 00000000..577d2406
--- /dev/null
+++ b/src/SAM.Core/Common/Behaviors/ContextMenuBehavior.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Threading;
+using DevExpress.Mvvm.UI.Interactivity;
+
+namespace SAM.Core.Behaviors;
+
+public class ContextMenuBehaviour : Behavior
+{
+ private const int DEFAULT_DELAY_IN_MS = 500;
+
+ public static readonly DependencyProperty ShowOnMouseOverProperty =
+ DependencyProperty.Register(nameof(ShowOnMouseOver), typeof(bool), typeof(ContextMenuBehaviour));
+
+ public static readonly DependencyProperty ShowOnMouseOverDelayProperty =
+ DependencyProperty.Register(nameof(ShowOnMouseOverDelay), typeof(int), typeof(ContextMenuBehaviour), new (DEFAULT_DELAY_IN_MS));
+
+ public static readonly DependencyProperty ShowOnLeftMouseDownProperty =
+ DependencyProperty.Register(nameof(ShowOnLeftMouseDown), typeof(bool), typeof(ContextMenuBehaviour));
+
+ private DispatcherTimer _timer;
+
+ public bool ShowOnMouseOver
+ {
+ get => (bool) GetValue(ShowOnMouseOverProperty);
+ set => SetValue(ShowOnMouseOverProperty, value);
+ }
+
+ public int ShowOnMouseOverDelay
+ {
+ get => (int) GetValue(ShowOnMouseOverDelayProperty);
+ set => SetValue(ShowOnMouseOverDelayProperty, value);
+ }
+
+ public bool ShowOnLeftMouseDown
+ {
+ get => (bool) GetValue(ShowOnLeftMouseDownProperty);
+ set => SetValue(ShowOnLeftMouseDownProperty, value);
+ }
+
+ protected override void OnAttached()
+ {
+ base.OnAttached();
+ AssociatedObject.MouseEnter += AssociatedObjectOnMouseEnter;
+ AssociatedObject.MouseLeave += AssociatedObjectOnMouseLeave;
+ AssociatedObject.MouseLeftButtonDown += AssociatedObjectOnMouseLeftButtonDown;
+ }
+
+ protected override void OnDetaching()
+ {
+ AssociatedObject.MouseEnter -= AssociatedObjectOnMouseEnter;
+ AssociatedObject.MouseLeave -= AssociatedObjectOnMouseLeave;
+ AssociatedObject.MouseLeftButtonDown -= AssociatedObjectOnMouseLeftButtonDown;
+ base.OnDetaching();
+ }
+
+ private void AssociatedObjectOnMouseLeave(object sender, MouseEventArgs e)
+ {
+ StopTimer();
+
+ e.Handled = true;
+ }
+
+ private void AssociatedObjectOnMouseEnter(object sender, MouseEventArgs e)
+ {
+ StartTimer();
+
+ e.Handled = true;
+ }
+
+ private void AssociatedObjectOnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ ShowContextMenu();
+
+ e.Handled = true;
+ }
+
+ private void StartTimer()
+ {
+ _timer = new (DispatcherPriority.Background);
+ _timer.Interval = TimeSpan.FromMilliseconds(ShowOnMouseOverDelay);
+ _timer.Tick += TimerOnTick;
+ _timer.Start();
+ }
+
+ private void StopTimer()
+ {
+ if (_timer != null)
+ {
+ _timer.Stop();
+ _timer.Tick -= TimerOnTick;
+ }
+
+ _timer = null;
+ }
+
+ private void TimerOnTick(object sender, EventArgs e)
+ {
+ StopTimer();
+
+ ShowContextMenu();
+ }
+
+ private void ShowContextMenu()
+ {
+ var menu = AssociatedObject?.ContextMenu;
+
+ if (menu == null) return;
+
+ menu.PlacementTarget = AssociatedObject;
+ menu.IsOpen = true;
+ }
+}
diff --git a/src/SAM.Core/Common/Enums/AchievementFilter.cs b/src/SAM.Core/Common/Enums/AchievementFilter.cs
index 72bf3ee7..d171edb8 100644
--- a/src/SAM.Core/Common/Enums/AchievementFilter.cs
+++ b/src/SAM.Core/Common/Enums/AchievementFilter.cs
@@ -2,21 +2,20 @@
using System.ComponentModel.DataAnnotations;
using SAM.Core.Converters;
-namespace SAM.Core
+namespace SAM.Core;
+
+[DefaultValue(All)]
+[TypeConverter(typeof(EnumDescriptionConverter))]
+public enum AchievementFilter
{
- [DefaultValue(All)]
- [TypeConverter(typeof(EnumDescriptionConverter))]
- public enum AchievementFilter
- {
- [Display(Name = "All", Description = "All achievements.")]
- All = 0,
- [Display(Name = "Unlocked", Description = "Unlocked achievements")]
- Unlocked = 1,
- [Display(Name = "Locked", Description = "Locked achievements")]
- Locked = 2,
- [Display(Name = "Modified", Description = "Modified achievements")]
- Modified = 3,
- [Display(Name = "Unmodified", Description = "Unmodified achievements")]
- Unmodified = 4
- }
+ [Display(Name = "All", Description = "All achievements.")]
+ All = 0,
+ [Display(Name = "Unlocked", Description = "Unlocked achievements")]
+ Unlocked = 1,
+ [Display(Name = "Locked", Description = "Locked achievements")]
+ Locked = 2,
+ [Display(Name = "Modified", Description = "Modified achievements")]
+ Modified = 3,
+ [Display(Name = "Unmodified", Description = "Unmodified achievements")]
+ Unmodified = 4
}
diff --git a/src/SAM.Core/Common/Enums/AppThemeBase.cs b/src/SAM.Core/Common/Enums/AppThemeBase.cs
index 59a30527..98eb5816 100644
--- a/src/SAM.Core/Common/Enums/AppThemeBase.cs
+++ b/src/SAM.Core/Common/Enums/AppThemeBase.cs
@@ -1,8 +1,7 @@
-namespace SAM.Core
+namespace SAM.Core;
+
+public enum AppThemeBase
{
- public enum AppThemeBase
- {
- Light,
- Dark
- }
+ Light,
+ Dark
}
diff --git a/src/SAM.Core/Common/Enums/GameInfoType.cs b/src/SAM.Core/Common/Enums/GameInfoType.cs
index 1183a4d1..3de9dba4 100644
--- a/src/SAM.Core/Common/Enums/GameInfoType.cs
+++ b/src/SAM.Core/Common/Enums/GameInfoType.cs
@@ -1,18 +1,17 @@
using System.ComponentModel;
-namespace SAM.Core
+namespace SAM.Core;
+
+public enum GameInfoType
{
- public enum GameInfoType
- {
- [Description("normal")]
- Normal,
- [Description("demo")]
- Demo,
- [Description("mod")]
- Mod,
- [Description("junk")]
- Junk,
- [Description("tool")]
- Tool
- }
+ [Description("normal")]
+ Normal,
+ [Description("demo")]
+ Demo,
+ [Description("mod")]
+ Mod,
+ [Description("junk")]
+ Junk,
+ [Description("tool")]
+ Tool
}
diff --git a/src/SAM.Core/Common/Enums/HomeViewType.cs b/src/SAM.Core/Common/Enums/HomeViewType.cs
index 71283349..f6e3e2a9 100644
--- a/src/SAM.Core/Common/Enums/HomeViewType.cs
+++ b/src/SAM.Core/Common/Enums/HomeViewType.cs
@@ -1,17 +1,16 @@
using System.ComponentModel;
-namespace SAM.Core
+namespace SAM.Core;
+
+[DefaultValue(FlowLayout)]
+public enum HomeViewType
{
- [DefaultValue(FlowLayout)]
- public enum HomeViewType
- {
- ///
- /// Uses the LibraryItemsControlView layout for the home screen.
- ///
- FlowLayout = 0,
- ///
- /// Uses the LibraryDataGridView layout for the home screen.
- ///
- DataGrid = 1
- }
+ ///
+ /// Uses the LibraryItemsControlView layout for the home screen.
+ ///
+ FlowLayout = 0,
+ ///
+ /// Uses the LibraryDataGridView layout for the home screen.
+ ///
+ DataGrid = 1
}
diff --git a/src/SAM.Core/Common/Enums/SAMExitCode.cs b/src/SAM.Core/Common/Enums/SAMExitCode.cs
index 947474ef..3bab9eba 100644
--- a/src/SAM.Core/Common/Enums/SAMExitCode.cs
+++ b/src/SAM.Core/Common/Enums/SAMExitCode.cs
@@ -1,14 +1,13 @@
-namespace SAM.Core
+namespace SAM.Core;
+
+public struct SAMExitCode
{
- public struct SAMExitCode
- {
- public const int SteamNotRunning = -7;
- public const int DispatcherException = -6;
- public const int AppDomainException = -5;
- public const int TaskException = -4;
- public const int InvalidAppId = -3;
- public const int NoAppIdArgument = -2;
- public const int UnhandledException = -1;
- public const int Normal = 0;
- }
+ public const int SteamNotRunning = -7;
+ public const int DispatcherException = -6;
+ public const int AppDomainException = -5;
+ public const int TaskException = -4;
+ public const int InvalidAppId = -3;
+ public const int NoAppIdArgument = -2;
+ public const int UnhandledException = -1;
+ public const int Normal = 0;
}
diff --git a/src/SAM.Core/Common/Enums/SettingDisplayType.cs b/src/SAM.Core/Common/Enums/SettingDisplayType.cs
index a01edad3..dd2a0d0e 100644
--- a/src/SAM.Core/Common/Enums/SettingDisplayType.cs
+++ b/src/SAM.Core/Common/Enums/SettingDisplayType.cs
@@ -1,31 +1,30 @@
using System.ComponentModel;
-namespace SAM.Core
+namespace SAM.Core;
+
+[DefaultValue(None)]
+public enum EditorType
{
- [DefaultValue(None)]
- public enum EditorType
- {
- None = 0,
- TextBox,
- TextArea,
- CheckBox,
- RadioButtons,
- NumericUpDown,
- Color,
- Theme,
- ComboBox,
- ListBox,
- PropertyGrid,
- DataGrid
- }
+ None = 0,
+ TextBox,
+ TextArea,
+ CheckBox,
+ RadioButtons,
+ NumericUpDown,
+ Color,
+ Theme,
+ ComboBox,
+ ListBox,
+ PropertyGrid,
+ DataGrid
+}
- [DefaultValue(Default)]
- public enum TextEditorType
- {
- Default = 0,
- FileName,
- DirectoryName,
- Email,
- Url
- }
+[DefaultValue(Default)]
+public enum TextEditorType
+{
+ Default = 0,
+ FileName,
+ DirectoryName,
+ Email,
+ Url
}
diff --git a/src/SAM.Core/Common/Enums/ShowWindowCommands.cs b/src/SAM.Core/Common/Enums/ShowWindowCommands.cs
index aaf85a7c..0d8abd28 100644
--- a/src/SAM.Core/Common/Enums/ShowWindowCommands.cs
+++ b/src/SAM.Core/Common/Enums/ShowWindowCommands.cs
@@ -1,21 +1,20 @@
-namespace SAM.Core
+namespace SAM.Core;
+
+public enum ShowWindowCommands : short
{
- public enum ShowWindowCommands : short
- {
- SW_HIDE = 0,
- SW_SHOWNORMAL = 1,
- SW_NORMAL = 1,
- SW_SHOWMINIMIZED = 2,
- SW_SHOWMAXIMIZED = 3,
- SW_MAXIMIZE = 3,
- SW_SHOWNOACTIVATE = 4,
- SW_SHOW = 5,
- SW_MINIMIZE = 6,
- SW_SHOWMINNOACTIVE = 7,
- SW_SHOWNA = 8,
- SW_RESTORE = 9,
- SW_SHOWDEFAULT = 10,
- SW_FORCEMINIMIZE = 11,
- SW_MAX = 11
- }
+ SW_HIDE = 0,
+ SW_SHOWNORMAL = 1,
+ SW_NORMAL = 1,
+ SW_SHOWMINIMIZED = 2,
+ SW_SHOWMAXIMIZED = 3,
+ SW_MAXIMIZE = 3,
+ SW_SHOWNOACTIVATE = 4,
+ SW_SHOW = 5,
+ SW_MINIMIZE = 6,
+ SW_SHOWMINNOACTIVE = 7,
+ SW_SHOWNA = 8,
+ SW_RESTORE = 9,
+ SW_SHOWDEFAULT = 10,
+ SW_FORCEMINIMIZE = 11,
+ SW_MAX = 11
}
diff --git a/src/SAM.Core/Common/Enums/SteamImageType.cs b/src/SAM.Core/Common/Enums/SteamImageType.cs
index 38c013d1..d83c5afc 100644
--- a/src/SAM.Core/Common/Enums/SteamImageType.cs
+++ b/src/SAM.Core/Common/Enums/SteamImageType.cs
@@ -1,33 +1,64 @@
-namespace SAM.Core
+using System;
+
+namespace SAM.Core;
+
+[Flags]
+public enum SteamImageType
{
- public enum SteamImageType
- {
- ClientIcon,
- Icon,
- ///
- /// logo.png
- ///
- Logo,
- ///
- /// header.jpg
- ///
- Header,
- ///
- /// library_hero.jpg
- ///
- LibraryHero,
- ///
- /// capsule_231x87.jpg
- ///
- SmallCapsule,
- ///
- /// capsule_467x181.jpg
- ///
- MediumCapsule,
- ///
- /// capsule_616x353.jpg
- ///
- LargeCapsule,
- AchievementIcon
- }
+ ///
+ /// {APPID}.jpg
+ ///
+ GridLandscape = 1,
+ ///
+ /// {APPID}p.jpg
+ ///
+ GridPortrait = 2,
+ ///
+ /// {APPID}_icon.jpg
+ ///
+ GridIcon = 4,
+ ///
+ /// {APPID}_hero.jpg
+ ///
+ GridHero = 8,
+ Grid = GridLandscape | GridPortrait | GridIcon | GridHero,
+ ClientIcon,
+ ///
+ /// {APPID}_icon.jpg
+ ///
+ Icon,
+ ///
+ /// logo.png
+ ///
+ Logo,
+ ///
+ /// header.jpg
+ ///
+ Header,
+ ///
+ /// {APPID}_library_600x900.jpg
+ ///
+ Library,
+ ///
+ /// {APPID}_library_hero.jpg
+ ///
+ LibraryHero,
+ ///
+ /// {APPID}_library_hero_blur.jpg
+ ///
+ LibraryHeroBlur,
+ ///
+ /// capsule_231x87.jpg
+ ///
+ SmallCapsule,
+ ///
+ /// capsule_467x181.jpg
+ ///
+ MediumCapsule,
+ ///
+ /// capsule_616x353.jpg
+ ///
+ LargeCapsule,
+ AchievementIcon,
+
}
diff --git a/src/SAM.Core/Common/Enums/SteamworksOperationResult.cs b/src/SAM.Core/Common/Enums/SteamworksOperationResult.cs
new file mode 100644
index 00000000..6ad3c13e
--- /dev/null
+++ b/src/SAM.Core/Common/Enums/SteamworksOperationResult.cs
@@ -0,0 +1,12 @@
+using System.ComponentModel;
+
+namespace SAM.Core;
+
+[DefaultValue(None)]
+public enum SteamworksOperationResult
+{
+ RateLimited = -2,
+ Failed = -1,
+ None = 0,
+ Success = 1
+}
diff --git a/src/SAM.Core/Common/Enums/ViewColumns.cs b/src/SAM.Core/Common/Enums/ViewColumns.cs
index a192e095..ac6e72f9 100644
--- a/src/SAM.Core/Common/Enums/ViewColumns.cs
+++ b/src/SAM.Core/Common/Enums/ViewColumns.cs
@@ -1,11 +1,10 @@
using System.ComponentModel;
-namespace SAM.Core
+namespace SAM.Core;
+
+[DefaultValue(6)]
+public enum ViewColumns : int
{
- [DefaultValue(6)]
- public enum ViewColumns : int
- {
- Min = 3,
- Max = 8
- }
+ Min = 3,
+ Max = 8
}
diff --git a/src/SAM.Core/Common/Extensions/MessengerExtensions.cs b/src/SAM.Core/Common/Extensions/MessengerExtensions.cs
new file mode 100644
index 00000000..80b5c3be
--- /dev/null
+++ b/src/SAM.Core/Common/Extensions/MessengerExtensions.cs
@@ -0,0 +1,27 @@
+using DevExpress.Mvvm;
+using SAM.Core.Messages;
+
+namespace SAM.Core.Extensions;
+
+public static class MessengerExtensions
+{
+ public static void SendAction(this IMessenger messenger, EntityType entityType, ActionType action)
+ {
+ messenger.Send(new ActionMessage(entityType, action));
+ }
+
+ public static void SendAction(this IMessenger messenger, T context, EntityType entityType, ActionType action)
+ {
+ messenger.Send(new ActionMessage(context, entityType, action));
+ }
+
+ public static void SendRequest(this IMessenger messenger, EntityType entityType, RequestType request)
+ {
+ messenger.Send(new RequestMessage(entityType, request));
+ }
+
+ public static void SendRequest(this IMessenger messenger, T context, EntityType entityType, RequestType request)
+ {
+ messenger.Send(new RequestMessage(context, entityType, request));
+ }
+}
diff --git a/src/SAM.Core/Common/Helpers/BrowserHelper.cs b/src/SAM.Core/Common/Helpers/BrowserHelper.cs
index cfa59b23..7eb60699 100644
--- a/src/SAM.Core/Common/Helpers/BrowserHelper.cs
+++ b/src/SAM.Core/Common/Helpers/BrowserHelper.cs
@@ -24,6 +24,7 @@ public static class BrowserHelper
private const string ACHIEVEMENTS_URI_FORMAT = @"steam://url/SteamIDAchievementsPage/{0}";
private const string STEAM_STORE_URI_FORMAT = @"https://store.steampowered.com/app/{0}";
+ private const string STEAMGRIDDB_URI_FORMAT = @"https://www.steamgriddb.com/steam/{0}";
private const string STEAMDB_URI_FORMAT = @"https://steamdb.info/app/{0}/graphs/";
private const string CARD_EXCHANGE_URI_FORMAT = @"https://www.steamcardexchange.net/index.php?gamepage-appid-{0}";
private const string PCGW_URI_FORMAT = @"https://www.pcgamingwiki.com/api/appid.php?appid={0}";
@@ -46,6 +47,13 @@ public static void ViewOnSteamStore(uint id)
OpenUrl(steamStorePage);
}
+ public static void ViewOnSteamGridDB(uint id)
+ {
+ var steamStorePage = string.Format(STEAMGRIDDB_URI_FORMAT, id);
+
+ OpenUrl(steamStorePage);
+ }
+
public static void ViewOnSteamDB(uint id)
{
var steamDbPage = string.Format(STEAMDB_URI_FORMAT, id);
@@ -78,7 +86,8 @@ public static void OpenUrl(string url)
{
try
{
- Process.Start(url);
+ var psi = new ProcessStartInfo(url) { UseShellExecute = true };
+ Process.Start(psi);
}
catch
{
diff --git a/src/SAM.Core/Common/Helpers/LocationHelper.cs b/src/SAM.Core/Common/Helpers/LocationHelper.cs
index da1d359a..844ac787 100644
--- a/src/SAM.Core/Common/Helpers/LocationHelper.cs
+++ b/src/SAM.Core/Common/Helpers/LocationHelper.cs
@@ -43,14 +43,14 @@ private static void Init()
var countries = countriesToken!.ToObject();
- _countries = new (countries);
+ _countries = [..countries];
_isInitialized = true;
}
internal class SteamLocation
{
- private readonly string _location;
+ private readonly string location;
public bool IsValid { get; }
public string City { get; set; }
@@ -71,14 +71,14 @@ protected SteamLocation()
public SteamLocation(string location)
{
- _location = location;
+ this.location = location;
var segments = location.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length is < 2 or > 3)
{
// if it's anything else don't bother trying to shorten it
- log.Warn($"Unknown location format ('{location}'). Location will not be shortened.");
+ log.Info($"Unknown location format ('{location}'). Location will not be shortened.");
return;
}
@@ -101,12 +101,12 @@ public SteamLocation(string location)
public string GetShortDisplayText()
{
- if (!IsValid) return _location;
+ if (!IsValid) return location;
var country = _countries.FirstOrDefault(c => c.Name.EqualsIgnoreCase(Country));
if (country == null)
{
- return _location;
+ return location;
}
var countryCode = country.Code;
@@ -125,7 +125,7 @@ internal class SteamCountry
public string Code { get; set; }
public string Name { get; set; }
public bool HasStates => States != null && States.Any();
- public List States { get; set; } = new ();
+ public List States { get; set; } = [];
public SteamState GetStateByName(string name)
{
diff --git a/src/SAM.Core/Common/Helpers/WebManager.cs b/src/SAM.Core/Common/Helpers/WebManager.cs
index 9726e3ad..e5a75aca 100644
--- a/src/SAM.Core/Common/Helpers/WebManager.cs
+++ b/src/SAM.Core/Common/Helpers/WebManager.cs
@@ -115,10 +115,10 @@ public static async Task DownloadImageAsync(string imageUrl, ICacheKey ca
var image = Image.FromStream(data);
- // if we were passed a key, cache the image so we can load it from cache next time
+ // if we were passed a key, cache the image, so we can load it from cache next time
if (cacheKey != null)
{
- CacheManager.CacheImage(cacheKey, image);
+ await CacheManager.CacheImageAsync(cacheKey, image);
}
return image;
diff --git a/src/SAM.Core/Common/Messages/ActionMessage.cs b/src/SAM.Core/Common/Messages/ActionMessage.cs
new file mode 100644
index 00000000..b72d4c23
--- /dev/null
+++ b/src/SAM.Core/Common/Messages/ActionMessage.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+
+namespace SAM.Core.Messages;
+
+public class ActionMessage(EntityType entityType, ActionType actionType)
+{
+ public static ActionMessage AppHidden { get; } = new (EntityType.Statistic, ActionType.Changed);
+ public static ActionMessage AppFavorited { get; } = new (EntityType.SteamApp, ActionType.Favorited);
+ public static ActionMessage StatChanged { get; } = new (EntityType.Statistic, ActionType.Changed);
+
+ public EntityType EntityType { get; } = entityType;
+ public ActionType ActionType { get; } = actionType;
+
+ protected virtual bool Equals(ActionMessage other)
+ {
+ return EntityType == other.EntityType && ActionType == other.ActionType;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+
+ return Equals((ActionMessage)obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(EntityType, ActionType);
+ }
+
+ public static bool operator ==(ActionMessage left, ActionMessage right)
+ {
+ if (ReferenceEquals(null, left)) return ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return false;
+ return left.GetHashCode() == right.GetHashCode();
+ }
+
+ public static bool operator !=(ActionMessage left, ActionMessage right)
+ {
+ if (ReferenceEquals(null, left)) return !ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return true;
+ return left.GetHashCode() != right.GetHashCode();
+ }
+}
+
+public class ActionMessage(T context, EntityType entityType, ActionType actionType) : ActionMessage(entityType, actionType)
+{
+ public T Context { get; } = context;
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Context, EntityType, ActionType);
+ }
+
+ protected bool Equals(ActionMessage other)
+ {
+ var contextEqual = EqualityComparer.Default.Equals(Context, other.Context);
+
+ return contextEqual && EntityType == other.EntityType && ActionType == other.ActionType;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+
+ return Equals((ActionMessage) obj);
+ }
+
+ public static bool operator ==(ActionMessage left, ActionMessage right)
+ {
+ if (ReferenceEquals(null, left)) return ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return false;
+ return left.GetHashCode() == right.GetHashCode();
+ }
+
+ public static bool operator !=(ActionMessage left, ActionMessage right)
+ {
+ if (ReferenceEquals(null, left)) return !ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return true;
+ return left.GetHashCode() != right.GetHashCode();
+ }
+}
diff --git a/src/SAM.Core/Common/Messages/ActionType.cs b/src/SAM.Core/Common/Messages/ActionType.cs
new file mode 100644
index 00000000..326812d2
--- /dev/null
+++ b/src/SAM.Core/Common/Messages/ActionType.cs
@@ -0,0 +1,38 @@
+namespace SAM.Core.Messages;
+
+///
+/// Indicates the action being requested or action that was performed.
+///
+///
+///
+public enum ActionType
+{
+ Adding,
+ Added,
+ Updating,
+ Updated,
+ Removing,
+ Removed,
+ Starting,
+ Started,
+ Uploading,
+ Uploaded,
+ Changing,
+ Changed,
+ Saving,
+ Saved,
+ Cancelling,
+ Cancelled,
+ Caching,
+ Cached,
+ Completed,
+ Refreshing,
+ Refreshed,
+ Grouping,
+ Filtering,
+ Sorting,
+ Favorited,
+ Unfavorited,
+ Hidden,
+ Visible
+}
diff --git a/src/SAM.Core/Common/Messages/EntityType.cs b/src/SAM.Core/Common/Messages/EntityType.cs
new file mode 100644
index 00000000..88e7e78f
--- /dev/null
+++ b/src/SAM.Core/Common/Messages/EntityType.cs
@@ -0,0 +1,17 @@
+namespace SAM.Core.Messages;
+
+public enum EntityType
+{
+ App,
+ ManagerApp,
+ SteamApp,
+ SteamAppSettings,
+ Setting,
+ User,
+ Image,
+ StoreInfo,
+ Library,
+ Theme,
+ Statistic,
+ Filter
+}
diff --git a/src/SAM.Core/Common/Messages/RequestMessage.cs b/src/SAM.Core/Common/Messages/RequestMessage.cs
new file mode 100644
index 00000000..22d1e438
--- /dev/null
+++ b/src/SAM.Core/Common/Messages/RequestMessage.cs
@@ -0,0 +1,83 @@
+using System;
+
+namespace SAM.Core.Messages;
+
+public class RequestMessage(EntityType entityType, RequestType requestType)
+{
+ public static RequestMessage LibraryRefresh { get; } = new (EntityType.Library, RequestType.Refresh);
+ public static RequestMessage FilterChanged { get; } = new (EntityType.Filter, RequestType.Apply);
+
+ public EntityType EntityType { get; } = entityType;
+ public RequestType RequestType { get; } = requestType;
+
+ protected virtual bool Equals(RequestMessage other)
+ {
+ return EntityType == other.EntityType && RequestType == other.RequestType;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+
+ return Equals((RequestMessage) obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(EntityType, RequestType);
+ }
+
+ public static bool operator ==(RequestMessage left, RequestMessage right)
+ {
+ if (ReferenceEquals(null, left)) return ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return false;
+ return left.GetHashCode() == right.GetHashCode();
+ }
+
+ public static bool operator !=(RequestMessage left, RequestMessage right)
+ {
+ if (ReferenceEquals(null, left)) return !ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return true;
+ return left.GetHashCode() != right.GetHashCode();
+ }
+}
+
+public class RequestMessage(T context, EntityType entityType, RequestType requestType) : RequestMessage(entityType, requestType)
+{
+ public T Context { get; } = context;
+
+ protected virtual bool Equals(RequestMessage other)
+ {
+ return EntityType == other.EntityType && RequestType == other.RequestType;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != GetType()) return false;
+
+ return Equals((RequestMessage) obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Context, EntityType, RequestType);
+ }
+
+ public static bool operator ==(RequestMessage left, RequestMessage right)
+ {
+ if (ReferenceEquals(null, left)) return ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return false;
+ return left.GetHashCode() == right.GetHashCode();
+ }
+
+ public static bool operator !=(RequestMessage left, RequestMessage right)
+ {
+ if (ReferenceEquals(null, left)) return !ReferenceEquals(right, null);
+ if (ReferenceEquals(null, right)) return true;
+ return left.GetHashCode() != right.GetHashCode();
+ }
+}
diff --git a/src/SAM.Core/Common/Messages/RequestType.cs b/src/SAM.Core/Common/Messages/RequestType.cs
new file mode 100644
index 00000000..b426d519
--- /dev/null
+++ b/src/SAM.Core/Common/Messages/RequestType.cs
@@ -0,0 +1,34 @@
+namespace SAM.Core.Messages;
+
+///
+/// Type of request to perform.
+///
+public enum RequestType
+{
+ Open,
+ Close,
+ Save,
+ SaveAs,
+ Create,
+ Delete,
+ Execute,
+ Upload,
+ Download,
+ Import,
+ Export,
+ Print,
+ Refresh,
+ Show,
+ Hide,
+ Add,
+ Remove,
+ Initialize,
+ Activate,
+ Cache,
+ Reset,
+ Sort,
+ Filter,
+ Group,
+ Apply,
+ Clear
+}
diff --git a/src/SAM.Core/Common/SplashScreen/SplashScreenHelper.cs b/src/SAM.Core/Common/SplashScreen/SplashScreenHelper.cs
index 71c498d5..afbfc129 100644
--- a/src/SAM.Core/Common/SplashScreen/SplashScreenHelper.cs
+++ b/src/SAM.Core/Common/SplashScreen/SplashScreenHelper.cs
@@ -5,7 +5,7 @@ namespace SAM.Core
public static class SplashScreenHelper
{
private static bool _isInitialized;
- private static UiWindow _splashWindow;
+ private static FluentWindow _splashWindow;
private static SplashScreenViewModel _splashScreenVm;
public static void Init()
diff --git a/src/SAM.Core/Common/SplashScreen/SplashScreenView.xaml b/src/SAM.Core/Common/SplashScreen/SplashScreenView.xaml
index 829aa3ca..d29c9d7b 100644
--- a/src/SAM.Core/Common/SplashScreen/SplashScreenView.xaml
+++ b/src/SAM.Core/Common/SplashScreen/SplashScreenView.xaml
@@ -1,24 +1,24 @@
-
+
-
+
@@ -26,7 +26,7 @@
-
+
@@ -48,7 +48,7 @@
-
+
-
+
diff --git a/src/SAM.Core/Models/SteamApp.cs b/src/SAM.Core/Models/SteamApp.cs
index e53ff8ae..e8135805 100644
--- a/src/SAM.Core/Models/SteamApp.cs
+++ b/src/SAM.Core/Models/SteamApp.cs
@@ -13,6 +13,7 @@
using log4net;
using SAM.API;
using SAM.Core.Extensions;
+using SAM.Core.Messages;
using SAM.Core.Settings;
using SAM.Core.Storage;
@@ -26,26 +27,28 @@ public partial class SteamApp : ViewModelBase
private Process _managerProcess;
- [GenerateProperty] private uint id;
- [GenerateProperty] private string name;
- [GenerateProperty] private GameInfoType gameInfoType;
+ [GenerateProperty] private uint _id;
+ [GenerateProperty] private string _name;
+ [GenerateProperty] private GameInfoType _gameInfoType;
public bool IsJunk => GameInfoType == GameInfoType.Junk;
public bool IsDemo => GameInfoType == GameInfoType.Demo;
public bool IsNormal => GameInfoType == GameInfoType.Normal;
public bool IsTool => GameInfoType == GameInfoType.Tool;
public bool IsMod => GameInfoType == GameInfoType.Mod;
- [GenerateProperty] private bool isLoading;
- [GenerateProperty] private bool loaded;
- [GenerateProperty] private string publisher;
- [GenerateProperty] private string developer;
- [GenerateProperty] private SteamStoreApp storeInfo;
- [GenerateProperty] private Image icon;
- [GenerateProperty] private Image header;
- [GenerateProperty] private Image capsule;
- [GenerateProperty] private string group;
- [GenerateProperty] private bool isHidden;
- [GenerateProperty] private bool isFavorite;
- [GenerateProperty] private bool isMenuOpen;
+ [GenerateProperty] private bool _isLoading;
+ [GenerateProperty] private bool _loaded;
+ [GenerateProperty] private string _publisher;
+ [GenerateProperty] private string _developer;
+ [GenerateProperty] private SteamStoreApp _storeInfo;
+ [GenerateProperty] private Image _icon;
+ [GenerateProperty] private Image _header;
+ [GenerateProperty] private Image _capsule;
+ [GenerateProperty] private Image _logo;
+ [GenerateProperty] private string _group;
+ [GenerateProperty] private bool _isHidden;
+ [GenerateProperty] private bool _isFavorite;
+ [GenerateProperty] private bool _isMenuOpen;
+ public bool StoreInfoLoaded => StoreInfo != null;
public SteamApp(uint id, GameInfoType type)
{
@@ -109,6 +112,12 @@ public void ViewOnSteam()
{
BrowserHelper.ViewOnSteamStore(Id);
}
+
+ [GenerateCommand]
+ public void ViewOnSteamGridDB()
+ {
+ BrowserHelper.ViewOnSteamGridDB(Id);
+ }
[GenerateCommand]
public void ViewOnSteamCardExchange()
@@ -134,14 +143,17 @@ public void ToggleVisibility()
IsHidden = !IsHidden;
SaveSettings();
+
+ Messenger.Default.Send(new (EntityType.Library, RequestType.Refresh));
}
[GenerateCommand]
public void ToggleFavorite()
{
IsFavorite = !IsFavorite;
-
SaveSettings();
+
+ Messenger.Default.Send(new (EntityType.Library, RequestType.Refresh));
}
public async Task Load()
@@ -155,14 +167,11 @@ public async Task Load()
// TODO: SteamApp shouldn't need to configure its cache directory structure
CacheManager.StorageManager.CreateDirectory($@"apps\{Id}");
- await Task.WhenAll(new []
- {
- Task.Run(LoadStoreInfo),
+ await Task.WhenAll([
+ Task.Run(LoadImages),
// load user preferences (hidden, favorite, etc) for app
Task.Run(LoadSettings)
- });
-
- await LoadImages();
+ ]);
}
catch (Exception e)
{
@@ -174,85 +183,42 @@ await Task.WhenAll(new []
IsLoading = false;
}
}
-
- private void LoadStoreInfo()
- {
- const int MAX_RETRIES = 3;
- var retryTime = TimeSpan.FromSeconds(30);
- var retries = 0;
- while (StoreInfo == null)
+ public async Task LoadImages()
+ {
+ try
{
- if (retries > MAX_RETRIES) break;
-
- try
+ // try and load the user's grid image for the app first
+ if (SteamClientManager.TryGetCachedAppImage(Id, SteamImageType.GridLandscape, out var gridHeader))
{
- StoreInfo = SteamworksManager.GetAppInfo(Id);
-
- if (StoreInfo == null) return;
+ Header = gridHeader;
- Publisher = StoreInfo.Publishers.FirstOrDefault();
- Developer = StoreInfo.Developers.FirstOrDefault();
+ return;
}
- catch (HttpRequestException hre) when (hre.StatusCode == HttpStatusCode.TooManyRequests)
- {
- var retrySb = new StringBuilder();
-
- retrySb.Append($"Request for store info on app '{Id}' returned {nameof(HttpStatusCode)} {HttpStatusCode.TooManyRequests} for {nameof(HttpStatusCode.TooManyRequests)}. ");
- retrySb.Append($"Waiting {retryTime.TotalSeconds} second(s) and then retrying...");
-
- log.Warn(retrySb);
- Thread.Sleep(retryTime);
- }
- catch (Exception e)
- {
- log.Error($"An error occurred attempting to load the store info for app {Id}. {e.Message}", e);
- break;
- }
- finally
+ // if they don't have a grid image then try and use the default header
+ if (SteamClientManager.TryGetCachedAppImage(Id, SteamImageType.Header, out var appHeader))
{
- retries++;
- }
- }
- }
+ Header = appHeader;
- public async Task LoadImages()
- {
- try
- {
- // TODO: Verify that the preferred HeaderImage method is consistent
- // TODO: For each type, loop through sources until one is successful
- if (!string.IsNullOrEmpty(StoreInfo?.HeaderImage))
+ return;
+ }
+
+ // if there's no default header either, then see if there's a logo we can use
+ if (SteamClientManager.TryGetCachedAppImage(Id, SteamImageType.Logo, out var appLogo))
{
- // TODO: The Uri file name parsing should be moved to the WebManager
- // TODO: Move image cache key creation to WebManager
- var uri = new Uri(StoreInfo.HeaderImage);
- var fileName = Path.GetFileName(uri.LocalPath);
- var key = CacheKeys.CreateAppImageCacheKey(Id, fileName);
+ log.Info($"Using {nameof(SteamImageType.Logo)} for app id {Id}.");
- var storeHeader = await WebManager.DownloadImageAsync(StoreInfo.HeaderImage, key);
+ Header = Logo = appLogo;
- // this assumes that we'll get a header back that we can use
- Header = storeHeader;
- }
- else
- {
- // this should run when Header is null regardless of whether or not
- // the StoreInfo.HeaderImage is null
- var appLogo = SteamClientManager.Default.GetAppLogo(Id);
- if (!string.IsNullOrEmpty(appLogo))
- {
- Header = SteamCdnHelper.DownloadImage(Id, SteamImageType.Logo, appLogo);
- }
+ return;
}
- // TODO: Change to be lazy loaded when needed
- var iconName = SteamClientManager.Default.GetAppIcon(Id);
- if (!string.IsNullOrEmpty(iconName))
- {
- Icon = SteamCdnHelper.DownloadImage(Id, SteamImageType.Icon, iconName);
- }
+ // if we don't have any cached header or logo image to use, then request the store info
+ // refresh so that we can download a header
+ SteamworksManager.LoadStoreInfo(this);
+
+ await Task.CompletedTask;
}
catch (Exception e)
{
@@ -290,4 +256,34 @@ private void SaveSettings()
log.Debug($"Saving {nameof(SteamAppSettings)} {settings}.");
}
+
+ protected void OnStoreInfoChanged()
+ {
+ // we didn't have a header OR a logo in the cache, so try downloading the header first
+ if (!string.IsNullOrEmpty(StoreInfo?.HeaderImage))
+ {
+ var storeHeader = SteamCdnHelper.DownloadImage(Id, SteamImageType.Header);
+
+ Header = storeHeader;
+
+ return;
+ }
+
+ // this should run when Header is null regardless of whether
+ // the StoreInfo.HeaderImage is null
+ var appLogoUrl = SteamClientManager.Default.GetAppLogo(Id);
+ if (!string.IsNullOrEmpty(appLogoUrl))
+ {
+ Header = Logo = SteamCdnHelper.DownloadImage(Id, SteamImageType.Logo, appLogoUrl);
+ }
+
+ // TODO: re-add this back when the other library views are available, until then nothing uses the icon
+ // TODO: Change to be lazy loaded when needed
+ //var iconName = SteamClientManager.Default.GetAppIcon(Id);
+ //
+ //if (!string.IsNullOrEmpty(iconName))
+ //{
+ // Icon = SteamCdnHelper.DownloadImage(Id, SteamImageType.Icon, iconName);
+ //}
+ }
}
diff --git a/src/SAM.Core/Models/SteamLibrary.cs b/src/SAM.Core/Models/SteamLibrary.cs
index 3053d614..4c80ffad 100644
--- a/src/SAM.Core/Models/SteamLibrary.cs
+++ b/src/SAM.Core/Models/SteamLibrary.cs
@@ -11,6 +11,7 @@
using DevExpress.Mvvm.CodeGenerators;
using log4net;
using SAM.API;
+using SAM.Core.Messages;
using SAM.Core.Storage;
namespace SAM.Core
@@ -42,6 +43,7 @@ public partial class SteamLibrary : BindableBase
[GenerateProperty] private int _modCount;
[GenerateProperty] private int _demoCount;
[GenerateProperty] private int _hiddenCount;
+ [GenerateProperty] private int _favoriteCount;
[GenerateProperty] private decimal _percentComplete;
[GenerateProperty] private bool _isLoading;
@@ -56,6 +58,8 @@ public SteamLibrary()
Items = [];
BindingOperations.EnableCollectionSynchronization(Items, _lock);
+ Messenger.Default.Register(this, OnRequestMessage);
+
_libraryWorker = new ()
{
Site = null,
@@ -67,7 +71,27 @@ public SteamLibrary()
_libraryWorker.DoWork += LibraryWorkerOnDoWork;
_libraryWorker.RunWorkerCompleted += LibraryWorkerOnRunWorkerCompleted;
}
-
+
+ private void OnRequestMessage(RequestMessage message)
+ {
+ try
+ {
+ if (message == null) return;
+ if (message.EntityType != EntityType.Library) return;
+
+ // if we received a refresh request refresh the counts
+ if (message.RequestType == RequestType.Refresh)
+ {
+ RefreshCounts();
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e);
+ throw;
+ }
+ }
+
public void Refresh(bool loadCache = false)
{
_resetEvent ??= new (false);
@@ -159,6 +183,8 @@ private void LibraryWorkerOnRunWorkerCompleted(object sender, RunWorkerCompleted
{
RefreshCounts();
+ Messenger.Default.Send(new ActionMessage(EntityType.Library, ActionType.Refreshed));
+
IsLoading = false;
}
@@ -281,6 +307,7 @@ private void RefreshCounts()
JunkCount = Items.Count(g => g.GameInfoType == GameInfoType.Junk);
DemoCount = Items.Count(g => g.GameInfoType == GameInfoType.Demo);
HiddenCount = Items.Count(g => g.IsHidden);
+ FavoriteCount = Items.Count(g => g.IsFavorite);
}
}
}
diff --git a/src/SAM.Core/Models/SteamUser.cs b/src/SAM.Core/Models/SteamUser.cs
index c9693cba..a854401c 100644
--- a/src/SAM.Core/Models/SteamUser.cs
+++ b/src/SAM.Core/Models/SteamUser.cs
@@ -1,4 +1,5 @@
-using System.Windows.Media;
+using System;
+using System.Windows.Media;
using System.Xml;
using DevExpress.Mvvm.CodeGenerators;
using log4net;
@@ -41,46 +42,55 @@ public SteamUser()
private void RefreshProfile()
{
- var client = SteamClientManager.Default;
+ try
+ {
+ var client = SteamClientManager.Default;
- SteamId64 = client.SteamUser.GetSteamId();
+ SteamId64 = client.SteamUser.GetSteamId();
- // TODO: need a way to get player level for friends
- PlayerLevel = client.SteamUser.GetPlayerSteamLevel();
+ // TODO: need a way to get player level for friends
+ PlayerLevel = client.SteamUser.GetPlayerSteamLevel();
- ProfileUrl = string.Format(PROFILE_URL_FORMAT, SteamId64);
+ ProfileUrl = string.Format(PROFILE_URL_FORMAT, SteamId64);
- var xmlFeedUrl = string.Format(PROFILE_XML_URL_FORMAT, SteamId64);
+ var xmlFeedUrl = string.Format(PROFILE_XML_URL_FORMAT, SteamId64);
- using var reader = new XmlTextReader(xmlFeedUrl);
- var doc = new XmlDocument();
- doc.Load(reader);
-
- SteamId = doc.GetValue(@"//steamID");
+ using var reader = new XmlTextReader(xmlFeedUrl);
+ var doc = new XmlDocument();
+ doc.Load(reader);
- AvatarIcon = doc.GetValue(@"//avatarIcon");
- AvatarMedium = doc.GetValue(@"//avatarMedium");
- AvatarFull = doc.GetValue(@"//avatarFull");
+ SteamId = doc.GetValue(@"//steamID");
- Avatar = ImageHelper.CreateSource(AvatarFull);
+ AvatarIcon = doc.GetValue(@"//avatarIcon");
+ AvatarMedium = doc.GetValue(@"//avatarMedium");
+ AvatarFull = doc.GetValue(@"//avatarFull");
- CustomUrl = doc.GetValue(@"//customUrl");
- MemberSince = doc.GetValue(@"//memberSince");
- Headline = doc.GetValue(@"//Headline");
- Location = doc.GetValue(@"//location");
+ Avatar = ImageHelper.CreateSource(AvatarFull);
- DisplayLocation = !string.IsNullOrEmpty(Location) ? LocationHelper.GetShortLocation(Location) : string.Empty;
+ CustomUrl = doc.GetValue(@"//customUrl");
+ MemberSince = doc.GetValue(@"//memberSince");
+ Headline = doc.GetValue(@"//Headline");
+ Location = doc.GetValue(@"//location");
- RealName = doc.GetValue(@"//realname");
-
- // TODO: no idea what this is for since mine and everyone i checked was empty
- //var steamRating = GetValue(doc, @"//steamRating");
-
- VacBanned = doc.GetValueAsBool(@"//vacBanned");
- IsLimitedAccount = doc.GetValueAsBool(@"//isLimitedAccount");
- RecentHoursPlayed = doc.GetValueAsDecimal(@"//hoursPlayed2Wk");
+ DisplayLocation = !string.IsNullOrEmpty(Location) ? LocationHelper.GetShortLocation(Location) : string.Empty;
- log.Debug($"Finished loading steam user {SteamId} ({SteamId64}) user profile.");
+ RealName = doc.GetValue(@"//realname");
+
+ // TODO: no idea what this is for since mine and everyone i checked was empty
+ //var steamRating = GetValue(doc, @"//steamRating");
+
+ VacBanned = doc.GetValueAsBool(@"//vacBanned");
+ IsLimitedAccount = doc.GetValueAsBool(@"//isLimitedAccount");
+ RecentHoursPlayed = doc.GetValueAsDecimal(@"//hoursPlayed2Wk");
+
+ log.Debug($"Finished loading steam user {SteamId} ({SteamId64}) user profile.");
+ }
+ catch (Exception e)
+ {
+ var message = $"An error occurred loading user information. {e.Message}";
+
+ log.Error(message, e);
+ }
}
}
}
diff --git a/src/SAM.Core/SAM.Core.csproj b/src/SAM.Core/SAM.Core.csproj
index c6172f9e..4b22c25f 100644
--- a/src/SAM.Core/SAM.Core.csproj
+++ b/src/SAM.Core/SAM.Core.csproj
@@ -1,7 +1,7 @@
- net7.0-windows
+ net8.0-windows
preview
x86
true
@@ -45,21 +45,21 @@
-
+
-
+
-
-
+
+
all
-
-
+
+
-
+
diff --git a/src/SAM.Core/Styles/AllResources.xaml b/src/SAM.Core/Styles/AllResources.xaml
index fe4adce3..2c988147 100644
--- a/src/SAM.Core/Styles/AllResources.xaml
+++ b/src/SAM.Core/Styles/AllResources.xaml
@@ -4,6 +4,7 @@
+
@@ -12,6 +13,7 @@
+
diff --git a/src/SAM.Core/Styles/Colors.xaml b/src/SAM.Core/Styles/Colors.xaml
index b5fc9c17..83327442 100644
--- a/src/SAM.Core/Styles/Colors.xaml
+++ b/src/SAM.Core/Styles/Colors.xaml
@@ -13,6 +13,11 @@
#FE7F49
#F04646
+
+ #795087
+ #795087
+ #464646
+
#6F6F6F
#333333
@@ -40,6 +45,10 @@
+
+
+
+
diff --git a/src/SAM.Core/Styles/Controls/AutoSuggestBox.xaml b/src/SAM.Core/Styles/Controls/AutoSuggestBox.xaml
new file mode 100644
index 00000000..c6fd5f01
--- /dev/null
+++ b/src/SAM.Core/Styles/Controls/AutoSuggestBox.xaml
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+ 1,1,1,0
+ 0,0,0,1
+ 10,8,0,0
+ 0,8,10,0
+ 0,5,4,0
+ 0,0,0,0
+ 24
+ 14
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SAM.Core/Styles/Controls/ButtonStyles.xaml b/src/SAM.Core/Styles/Controls/ButtonStyles.xaml
index e8fa625f..00e35181 100644
--- a/src/SAM.Core/Styles/Controls/ButtonStyles.xaml
+++ b/src/SAM.Core/Styles/Controls/ButtonStyles.xaml
@@ -5,12 +5,8 @@
-
+
-
-
\ No newline at end of file
diff --git a/src/SAM.Core/Styles/Controls/ContainerStyles.xaml b/src/SAM.Core/Styles/Controls/ContainerStyles.xaml
index 7b3cf3b4..0ac4c110 100644
--- a/src/SAM.Core/Styles/Controls/ContainerStyles.xaml
+++ b/src/SAM.Core/Styles/Controls/ContainerStyles.xaml
@@ -4,7 +4,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SAM.Core/Styles/Controls/ToggleButtonStyles.xaml b/src/SAM.Core/Styles/Controls/ToggleButtonStyles.xaml
new file mode 100644
index 00000000..ec78219f
--- /dev/null
+++ b/src/SAM.Core/Styles/Controls/ToggleButtonStyles.xaml
@@ -0,0 +1,400 @@
+
+
+
+
+
+
+
+
+ 11,5,11,6
+ 1
+ 0,0,8,0
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SAM.Core/Styles/Controls/WindowStyles.xaml b/src/SAM.Core/Styles/Controls/WindowStyles.xaml
index 1228c380..3d116f3f 100644
--- a/src/SAM.Core/Styles/Controls/WindowStyles.xaml
+++ b/src/SAM.Core/Styles/Controls/WindowStyles.xaml
@@ -2,7 +2,11 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
-
-
\ No newline at end of file
diff --git a/src/SAM.Core/Styles/Images/sam_icon_logo.svg b/src/SAM.Core/Styles/Images/sam_icon_logo.svg
new file mode 100644
index 00000000..071c9ba3
--- /dev/null
+++ b/src/SAM.Core/Styles/Images/sam_icon_logo.svg
@@ -0,0 +1 @@
+
diff --git a/src/SAM.Core/ViewModels/HomeViewModel.cs b/src/SAM.Core/ViewModels/HomeViewModel.cs
index 3f80685e..b31e537a 100644
--- a/src/SAM.Core/ViewModels/HomeViewModel.cs
+++ b/src/SAM.Core/ViewModels/HomeViewModel.cs
@@ -1,10 +1,15 @@
using System;
+using System.Collections.Generic;
using System.ComponentModel;
+using System.Linq;
using System.Windows.Data;
+using System.Windows.Documents;
+using DevExpress.Mvvm;
using DevExpress.Mvvm.CodeGenerators;
using log4net;
using SAM.Core.Converters;
using SAM.Core.Extensions;
+using SAM.Core.Messages;
namespace SAM.Core.ViewModels
{
@@ -27,6 +32,7 @@ public partial class HomeViewModel
[GenerateProperty] private string _filterTool;
[GenerateProperty] private int _tileWidth = 100;
[GenerateProperty] private ICollectionView _itemsView;
+ [GenerateProperty] private List _suggestions;
[GenerateProperty] private SteamLibrary _library;
public SteamApp SelectedItem
@@ -38,25 +44,31 @@ public SteamApp SelectedItem
public HomeViewModel()
{
Refresh();
+
+ Messenger.Default.Register(this, OnActionMessage);
}
- private void ItemsViewSourceOnFilter(object sender, FilterEventArgs e)
+ [GenerateCommand]
+ public void ToggleShowHidden()
{
- if (e.Item is not SteamApp app) throw new ArgumentException(nameof(e.Item));
+ ShowHidden = !ShowHidden;
+ }
- var hasNameFilter = !string.IsNullOrWhiteSpace(FilterText);
- var isNameMatch = !hasNameFilter || app.Name.ContainsIgnoreCase(FilterText) || app.Id.ToString().Contains(FilterText);
- var isJunkFiltered = !FilterJunk || app.IsJunk;
- var isHiddenFiltered = ShowHidden || !app.IsHidden;
- var isNonFavoriteFiltered = !FilterFavorites || app.IsFavorite;
-
- e.Accepted = isNameMatch && isJunkFiltered && isHiddenFiltered && isNonFavoriteFiltered;
+ [GenerateCommand]
+ public void UnHideAll()
+ {
+ // TODO: consider adding confirmation before clearing the user's hidden apps
+ var hidden = Library!.Items.Where(a => a.IsHidden).ToList();
+
+ hidden.ForEach(a => a.ToggleVisibility());
+
+ Messenger.Default.Send(new (EntityType.Library, RequestType.Refresh));
}
[GenerateCommand]
public void Loaded()
{
- log.Info($"{nameof(HomeViewModel)} {nameof(Loaded)}");
+ log.Debug($"{nameof(HomeViewModel)} {nameof(Loaded)}");
}
[GenerateCommand]
@@ -92,6 +104,14 @@ public void Refresh(bool force = false)
_itemsViewSource.IsLiveGroupingRequested = false;
}
+ // suggestions are sorted by favorites first, then normal (non-favorite & non-hidden) apps,
+ // and then any hidden apps
+ Suggestions = Library.Items
+ .OrderByDescending(a => a.IsFavorite)
+ .ThenBy(a => a.IsHidden)
+ .ThenBy(a => a.Name)
+ .Select(a => a.Name).ToList();
+
_loading = false;
}
@@ -101,7 +121,21 @@ protected void OnFilterTextChanged()
ItemsView!.Refresh();
}
+
+ protected void OnShowHiddenChanged()
+ {
+ if (_loading) return;
+
+ ItemsView!.Refresh();
+ }
+ protected void OnFilterFavoritesChanged()
+ {
+ if (_loading) return;
+
+ ItemsView!.Refresh();
+ }
+
protected void OnEnableGroupingChanged()
{
if (_loading) return;
@@ -117,5 +151,27 @@ protected void OnEnableGroupingChanged()
}
}
}
+
+ private void OnActionMessage(ActionMessage message)
+ {
+ // on library refresh completed
+ if (message.EntityType == EntityType.Library && message.ActionType == ActionType.Refreshed)
+ {
+ Refresh();
+ }
+ }
+
+ private void ItemsViewSourceOnFilter(object sender, FilterEventArgs e)
+ {
+ if (e.Item is not SteamApp app) throw new ArgumentException(nameof(e.Item));
+
+ var hasNameFilter = !string.IsNullOrWhiteSpace(FilterText);
+ var isNameMatch = !hasNameFilter || app.Name.ContainsIgnoreCase(FilterText) || app.Id.ToString().Contains(FilterText);
+ var isJunkFiltered = !FilterJunk || app.IsJunk;
+ var isHiddenFiltered = ShowHidden || !app.IsHidden;
+ var isNonFavoriteFiltered = !FilterFavorites || app.IsFavorite;
+
+ e.Accepted = isNameMatch && isJunkFiltered && isHiddenFiltered && isNonFavoriteFiltered;
+ }
}
}
diff --git a/src/SAM.Core/ViewModels/MainWindowViewModelBase.cs b/src/SAM.Core/ViewModels/MainWindowViewModelBase.cs
index 0cd3af7d..33b41e14 100644
--- a/src/SAM.Core/ViewModels/MainWindowViewModelBase.cs
+++ b/src/SAM.Core/ViewModels/MainWindowViewModelBase.cs
@@ -1,5 +1,6 @@
using DevExpress.Mvvm.CodeGenerators;
using SAM.Core.Behaviors;
+using Wpf.Ui.Appearance;
namespace SAM.Core.ViewModels
{
@@ -14,7 +15,13 @@ public partial class MainWindowViewModelBase
public MainWindowViewModelBase()
{
+ ApplicationThemeManager.Apply(ApplicationTheme.Dark);
+ }
+ [GenerateCommand]
+ protected void OnLoaded()
+ {
+ SplashScreenHelper.Close();
}
private void OnSubTitleChanged()
diff --git a/src/SAM.Core/ViewModels/SteamGameViewModel.cs b/src/SAM.Core/ViewModels/SteamGameViewModel.cs
index 00e47279..af332f98 100644
--- a/src/SAM.Core/ViewModels/SteamGameViewModel.cs
+++ b/src/SAM.Core/ViewModels/SteamGameViewModel.cs
@@ -22,13 +22,15 @@ public partial class SteamGameViewModel
public virtual ICurrentWindowService CurrentWindow => GetService();
+ private bool _loading = true;
+ private CollectionViewSource _achievementsViewSource;
+
[UsedImplicitly]
private readonly ObservableHandler statsHandler;
[UsedImplicitly]
private ObservableCollectionPropertyHandler, SteamAchievement> _achievementsPropertyHandler;
- // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable
// ReSharper disable once InconsistentNaming
private readonly SteamStatsManager _statsManager;
@@ -261,30 +263,46 @@ public void UnlockAllAchievements()
protected void Refresh()
{
- if (!Achievements!.Any()) return;
+ _loading = true;
+
+ _achievementsViewSource = new ()
+ {
+ Source = Achievements
+ };
+
+ AchievementsView = _achievementsViewSource.View;
+
+ using (_achievementsViewSource.DeferRefresh())
+ {
+ _achievementsViewSource.Filter += AchievementFilter;
+
+ _achievementsViewSource.SortDescriptions.Clear();
+ _achievementsViewSource.SortDescriptions.Add(new (nameof(SteamAchievement.IsModified), ListSortDirection.Descending));
+ _achievementsViewSource.SortDescriptions.Add(new (nameof(SteamAchievement.IsAchieved), ListSortDirection.Ascending));
+ _achievementsViewSource.SortDescriptions.Add(new (nameof(SteamAchievement.Name), ListSortDirection.Ascending));
+
+ _achievementsViewSource.LiveFilteringProperties.Clear();
+ _achievementsViewSource.LiveFilteringProperties.Add(nameof(SteamAchievement.IsModified));
+ _achievementsViewSource.LiveFilteringProperties.Add(nameof(SteamAchievement.IsAchieved));
- AllowUnlockAll = Achievements.Any(a => !a.IsAchieved);
+ _achievementsViewSource.IsLiveFilteringRequested = true;
+ _achievementsViewSource.IsLiveSortingRequested = true;
+ _achievementsViewSource.IsLiveGroupingRequested = false;
+ }
+
+ _loading = false;
}
protected void OnManagerIsModifiedChanged()
{
IsModified = _statsManager.IsModified;
- Refresh();
+ AllowUnlockAll = Achievements!.Any(a => !a.IsAchieved);
}
private void ManagerAchievementsChanged(SteamStatsManager obj)
{
Achievements = new (obj.Achievements);
-
- AchievementsView = (CollectionView) CollectionViewSource.GetDefaultView(Achievements);
- AchievementsView!.Filter = AchievementFilter;
- AchievementsView.SortDescriptions.Add(new (nameof(SteamAchievement.IsModified), ListSortDirection.Descending));
- AchievementsView.SortDescriptions.Add(new (nameof(SteamAchievement.IsAchieved), ListSortDirection.Ascending));
- AchievementsView.SortDescriptions.Add(new (nameof(SteamAchievement.Name), ListSortDirection.Ascending));
-
- _achievementsPropertyHandler = new ObservableCollectionPropertyHandler, SteamAchievement>(Achievements)
- .Add(a => a.IsModified, OnAchievementModifiedHandler);
}
private void OnAchievementModifiedHandler(ObservableCollection arg1, SteamAchievement arg2)
@@ -294,28 +312,52 @@ private void OnAchievementModifiedHandler(ObservableCollection
protected void OnSearchTextChanged()
{
- AchievementsView!.Refresh();
+ if (_loading) return;
+
+ AchievementsView?.Refresh();
}
- private bool AchievementFilter(object obj)
+ private void OnAchievementsChanged()
{
+ AllowUnlockAll = Achievements!.Any(a => !a.IsAchieved);
+
+ _achievementsPropertyHandler = new ObservableCollectionPropertyHandler, SteamAchievement>(Achievements)
+ .Add(a => a.IsModified, OnAchievementModifiedHandler);
+
+ Refresh();
+ }
+
+ private void AchievementFilter(object sender, FilterEventArgs args)
+ {
+ var obj = args.Item;
if (obj is not SteamAchievement achievement)
{
throw new InvalidOperationException($"{nameof(obj)} must be of type {nameof(SteamAchievement)}.");
}
- if (string.IsNullOrEmpty(SearchText))
+ // if we have search text that was entered
+ if (!string.IsNullOrEmpty(SearchText))
{
- return true;
+ // if it's not a match on the name or description then filter it out
+ if (!achievement.Name.ContainsIgnoreCase(SearchText)
+ && !achievement.Description.ContainsIgnoreCase(SearchText))
+ {
+ args.Accepted = false;
+ return;
+ }
}
- if (achievement.Name.ContainsIgnoreCase(SearchText)
- || achievement.Description.ContainsIgnoreCase(SearchText))
+ var accepted = SelectedAchievementFilter switch
{
- return true;
- }
+ Core.AchievementFilter.Locked => !achievement.IsAchieved,
+ Core.AchievementFilter.Unlocked => achievement.IsAchieved,
+ Core.AchievementFilter.Modified => achievement.IsModified,
+ Core.AchievementFilter.Unmodified => !achievement.IsModified,
+ Core.AchievementFilter.All => true,
+ _ => true
+ };
- return false;
+ args.Accepted = accepted;
}
private void OnShowHiddenChanged()
@@ -323,9 +365,9 @@ private void OnShowHiddenChanged()
Achievements.ForEach(a => a.RefreshDescription(ShowHidden));
}
- private void OnAchievementsChanged()
+ private void OnSelectedAchievementFilterChanged()
{
- Refresh();
+ AchievementsView?.Refresh();
}
private void ManagerStatisticsChanged(SteamStatsManager obj)
diff --git a/src/SAM.Core/Views/Stats/StatsView.xaml b/src/SAM.Core/Views/Stats/StatsView.xaml
index 719ec103..1391f702 100644
--- a/src/SAM.Core/Views/Stats/StatsView.xaml
+++ b/src/SAM.Core/Views/Stats/StatsView.xaml
@@ -16,7 +16,9 @@
-
+
+
+
@@ -73,48 +102,82 @@
+ Icon="{ui:SymbolIcon AppGeneric24}" />
-
+
-
+
+ Icon="{ui:SymbolIcon Database24}" />
+
+ Icon="{ui:SymbolIcon ShoppingBag24}" />
+ Icon="{ui:SymbolIcon Server24}" />
+ Icon="{ui:SymbolIcon ArrowSwap24}" />
+ Icon="{ui:SymbolIcon Copy24}" />
@@ -135,6 +198,9 @@
+
+
+
@@ -158,6 +224,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SAM/Views/MenuView.xaml b/src/SAM/Views/MenuView.xaml
index 15da8309..664f9f8a 100644
--- a/src/SAM/Views/MenuView.xaml
+++ b/src/SAM/Views/MenuView.xaml
@@ -4,11 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dxmvvm="http://schemas.devexpress.com/winfx/2008/xaml/mvvm"
- xmlns:vm="clr-namespace:SAM.ViewModels"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
+ xmlns:sam="urn:sam"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
- d:DataContext="{d:DesignInstance Type=vm:MainWindowViewModel, IsDesignTimeCreatable=False}">
+ d:DataContext="{d:DesignInstance Type=sam:HomeViewModel, IsDesignTimeCreatable=False}">
@@ -16,10 +16,16 @@
-
+
+
+
+
+
diff --git a/src/SAM/Views/StatusBarView.xaml b/src/SAM/Views/StatusBarView.xaml
index 176edb5e..9291c14c 100644
--- a/src/SAM/Views/StatusBarView.xaml
+++ b/src/SAM/Views/StatusBarView.xaml
@@ -11,64 +11,141 @@
d:DataContext="{d:DesignInstance Type=sam:HomeViewModel, IsDesignTimeCreatable=False}">
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+ Visibility="{Binding Library.ModCount, FallbackValue=0, TargetNullValue=0, Converter={dxmvvm:NumericToVisibilityConverter}}" />
+ Visibility="{Binding Library.ToolCount, FallbackValue=0, TargetNullValue=0, Converter={dxmvvm:NumericToVisibilityConverter}}" />
+ Visibility="{Binding Library.DemoCount, FallbackValue=0, TargetNullValue=0, Converter={dxmvvm:NumericToVisibilityConverter}}" />
+ Content="{Binding Library.JunkCount, FallbackValue=0, TargetNullValue=0, Mode=OneWay}" ContentStringFormat="{}{0} Junk"
+ Visibility="{Binding Library.JunkCount, FallbackValue=0, TargetNullValue=0, Converter={dxmvvm:NumericToVisibilityConverter}}" />
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -78,11 +155,10 @@
Value="{Binding TileWidth, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"
ToolTip="{Binding RelativeSource={RelativeSource Self}, Path=Value}" />
-
-
-
+
-
+
+
@@ -31,7 +32,7 @@
-
+
diff --git a/src/SAM/sam.ico b/src/SAM/sam.ico
index c9c9de88..a7dcf66d 100644
Binary files a/src/SAM/sam.ico and b/src/SAM/sam.ico differ