From 30cf049f23c6a34f194562e18b5d3b30d128d46c Mon Sep 17 00:00:00 2001 From: achiez Date: Tue, 12 May 2026 16:36:37 +0300 Subject: [PATCH 01/13] refactor(auth): redesign Steam audience and web domain authorization model - Rename AuthorizedDomains to WebDomains - Introduce explicit Steam audience constants and web audience mappings - Improve Steam token/domain resolution and cookie installation flow - Allow mobile-issued tokens with "web" audience to be used for web domains - Align authorization logic closer to actual Steam audience behavior - Rename cookie APIs from Add* to Set* where appropriate --- src/NebulaAuth/Model/MAAC/PortableMaClient.cs | 2 +- src/NebulaAuth/Model/MaClient.cs | 2 +- .../SessionHandler/SessionHandler_API.cs | 2 +- .../Authentication/AdmissionHelper.cs | 154 +++++++++--------- src/SteamLibForked/Core/SteamDomains.cs | 64 ++++---- .../Models/Core/Session/MobileSessionData.cs | 34 ++-- src/SteamLibForked/Web/ClientBuilder.cs | 2 +- 7 files changed, 136 insertions(+), 124 deletions(-) diff --git a/src/NebulaAuth/Model/MAAC/PortableMaClient.cs b/src/NebulaAuth/Model/MAAC/PortableMaClient.cs index 6395256..23555e6 100644 --- a/src/NebulaAuth/Model/MAAC/PortableMaClient.cs +++ b/src/NebulaAuth/Model/MAAC/PortableMaClient.cs @@ -62,7 +62,7 @@ public partial class PortableMaClient : ObservableObject, IDisposable SetStatus(newStatus); ClientHandler.CookieContainer.ClearAllCookies(); - ClientHandler.CookieContainer.SetSteamMobileCookiesWithMobileToken(sessionData); + ClientHandler.CookieContainer.SetSteamMobileCookies(sessionData); } diff --git a/src/NebulaAuth/Model/MaClient.cs b/src/NebulaAuth/Model/MaClient.cs index 115f5e3..74598a2 100644 --- a/src/NebulaAuth/Model/MaClient.cs +++ b/src/NebulaAuth/Model/MaClient.cs @@ -38,7 +38,7 @@ public static class MaClient { ClientHandler.CookieContainer.ClearAllCookies(); if (account == null) return; - ClientHandler.CookieContainer.SetSteamMobileCookiesWithMobileToken(account.SessionData); + ClientHandler.CookieContainer.SetSteamMobileCookies(account.SessionData); Proxy.SetData(account.Proxy?.Data); } diff --git a/src/NebulaAuth/Model/SessionHandler/SessionHandler_API.cs b/src/NebulaAuth/Model/SessionHandler/SessionHandler_API.cs index b644462..fe40011 100644 --- a/src/NebulaAuth/Model/SessionHandler/SessionHandler_API.cs +++ b/src/NebulaAuth/Model/SessionHandler/SessionHandler_API.cs @@ -30,7 +30,7 @@ public partial class SessionHandler //API //Trigger PropertyChanged event for PortableMaClient handling session updated from MaClient mafile.SetSessionData(mafile.SessionData); await Storage.UpdateMafileAsync(mafile); - chp.Handler.CookieContainer.SetSteamMobileCookiesWithMobileToken(mafile.SessionData); + chp.Handler.CookieContainer.SetSteamMobileCookies(mafile.SessionData); } public static async Task LoginAgain(HttpClientHandlerPair chp, Mafile mafile, string password, bool savePassword) diff --git a/src/SteamLibForked/Authentication/AdmissionHelper.cs b/src/SteamLibForked/Authentication/AdmissionHelper.cs index 30ab7b2..57ece1c 100644 --- a/src/SteamLibForked/Authentication/AdmissionHelper.cs +++ b/src/SteamLibForked/Authentication/AdmissionHelper.cs @@ -54,33 +54,20 @@ public static class AdmissionHelper return; } - - AddRefreshToken(container, sessionData.RefreshToken); + container.SetSteamRefreshToken(sessionData.RefreshToken); var community = SteamDomains.GetDomainUri(SteamDomain.Community); container.Add(community, new Cookie(SESSION_ID_COOKIE_NAME, sessionData.SessionId, "/")); container.Add(community, new Cookie(LANGUAGE_COOKIE_NAME, setLanguage, "/")); TransferCommunityCookies(container); - foreach (var domain in SteamDomains.AuthDomains) + foreach (var domain in SteamDomains.WebDomains) { var token = sessionData.GetToken(domain); if (token == null) continue; - AddTokenCookie(container, token.Value); + container.SetSteamAccessToken(token.Value); } } - public static void SetDomainCookie(this CookieContainer container, SteamDomain domain, SteamAuthToken token) - { - var uri = SteamDomains.GetDomainUri(domain); - foreach (var cookie in container.GetCookies(uri) - .Where(c => !c.Expired && c.Name.EqualsIgnoreCase(ACCESS_COOKIE_NAME))) - { - cookie.Expired = true; - } - - AddTokenCookie(container, token); - } - /// /// Clear and set new session /// @@ -95,70 +82,22 @@ public static class AdmissionHelper return; } - AddRefreshToken(container, mobileSession.RefreshToken); + container.SetSteamRefreshToken(mobileSession.RefreshToken); var community = SteamDomains.GetDomainUri(SteamDomain.Community); container.Add(community, new Cookie("steamid", mobileSession.SteamId.Steam64.ToString())); container.Add(community, new Cookie(SESSION_ID_COOKIE_NAME, mobileSession.SessionId)); container.Add(community, new Cookie(LANGUAGE_COOKIE_NAME, setLanguage)); TransferCommunityCookies(container); - foreach (var domain in SteamDomains.AuthDomains) + foreach (var domain in SteamDomains.WebDomains) { var token = mobileSession.GetToken(domain); if (token == null) continue; - AddTokenCookie(container, token.Value); + var domainUri = SteamDomains.GetDomainUri(domain); + container.SetSteamAccessTokenUnsafe(token.Value, domainUri); } } - /// - /// Clear and set new session. Not recommended. Uses for domain - /// instead of its own cookie. It's okay to use it only for confirmations. But - /// Market, Trading and other pages won't be authorized - /// - public static void SetSteamMobileCookiesWithMobileToken(this CookieContainer container, - IMobileSessionData? mobileSession, - string setLanguage = "english") - { - container.ClearSteamCookies(setLanguage); - container.AddMinimalMobileCookies(); - - if (mobileSession == null) - { - TransferCommunityCookies(container); - return; - } - - - AddRefreshToken(container, mobileSession.RefreshToken); - - var community = SteamDomains.GetDomainUri(SteamDomain.Community); - container.Add(community, new Cookie("steamid", mobileSession.SteamId.Steam64.ToString())); - container.Add(community, new Cookie(SESSION_ID_COOKIE_NAME, mobileSession.SessionId)); - container.Add(community, new Cookie(LANGUAGE_COOKIE_NAME, setLanguage)); - TransferCommunityCookies(container); - - var domainCookieSet = false; - foreach (var domain in SteamDomains.AllDomains) - { - var token = mobileSession.GetToken(domain); - if (token == null || token.Value.IsExpired) continue; - if (domain == SteamDomain.Community) - domainCookieSet = true; - AddTokenCookie(container, token.Value); - } - - var mobileToken = mobileSession.GetMobileToken(); - if (!domainCookieSet && mobileToken is {IsExpired: false}) - { - var domain = SteamDomains.GetDomainUri(SteamDomain.Community); - container.Add(domain, new Cookie(ACCESS_COOKIE_NAME, mobileToken.Value.SignedToken) - { - HttpOnly = true, - Secure = true, - Expires = mobileToken.Value.Expires.ToLocalDateTime() - }); - } - } public static void AddMinimalMobileCookies(this CookieContainer container) { @@ -229,27 +168,93 @@ public static class AdmissionHelper } } - public static void AddRefreshToken(CookieContainer container, SteamAuthToken token) + /// + /// Sets a Steam refresh token as a cookie to the specified cookie container. + /// + /// + /// The added cookie will have its expiration set according to the token's expiration time. This + /// method is typically used to enable authenticated requests to Steam services that require a refresh + /// token. + /// + /// The cookie container to which the Steam refresh token cookie will be added. Cannot be null. + /// + /// The Steam authentication token to add as a refresh cookie. Must be of type Refresh or + /// MobileRefresh. + /// + /// Thrown if the token is not of type Refresh or MobileRefresh. + public static void SetSteamRefreshToken(this CookieContainer container, SteamAuthToken token) { if (token.Type is not (SteamAccessTokenType.Refresh or SteamAccessTokenType.MobileRefresh)) throw new ArgumentException( $"Token must be of type Refresh or MobileRefresh. Provided token has type: {token.Type}", nameof(token)); - var refreshToken = token.SignedToken; - container.Add(SteamLoginUri, new Cookie(REFRESH_COOKIE_NAME, refreshToken) + SetSteamRefreshTokenUnsafe(container, token, SteamLoginUri); + } + + /// + /// Sets a Steam refresh token as a cookie to the specified cookie container for the given domain. + /// + /// + /// This method does not perform validation on the input parameters. Callers must ensure that the + /// provided values are valid and appropriate for use. + /// + /// The cookie container to which the refresh token cookie will be added. Cannot be null. + /// + /// The Steam authentication token containing the signed token value and expiration information. Cannot + /// be null. + /// + /// The URI of the domain for which the refresh token cookie should be set. Cannot be null. + public static void SetSteamRefreshTokenUnsafe(CookieContainer container, SteamAuthToken token, Uri domainUri) + { + container.Add(domainUri, new Cookie(REFRESH_COOKIE_NAME, token.SignedToken) { Expires = token.Expires.ToLocalDateTime() }); } - public static void AddTokenCookie(CookieContainer container, SteamAuthToken token) + /// + /// Sets a Steam access token to the specified cookie container for use with Steam web requests. + /// + /// + /// This method is intended for standard web access tokens bound to a specific Steam web domain. + /// Mobile tokens are intentionally rejected, since their audiences are capability-based rather + /// than domain-based and may grant access to multiple web domains. + /// + /// The cookie container to which the Steam access token will be added. Cannot be null. + /// + /// The Steam access token to add. Must be of type AccessToken and associated with a valid Steam + /// domain. + /// + /// Thrown if the token is not of type AccessToken. + public static void SetSteamAccessToken(this CookieContainer container, SteamAuthToken token) { + if (token.Type == SteamAccessTokenType.Mobile) + throw new ArgumentException( + $"Mobile access tokens cannot be added using this method. Use SetSteamAccessTokenUnsafe instead. Provided token has type: {token.Type}", + nameof(token)); if (token.Type is not SteamAccessTokenType.AccessToken) throw new ArgumentException($"Token must be of type AccessToken. Provided token has type: {token.Type}", nameof(token)); - var domain = SteamDomains.GetDomainUri(token.Domain); - container.Add(domain, new Cookie(ACCESS_COOKIE_NAME, token.SignedToken) + var domainUri = SteamDomains.GetDomainUri(token.Domain); + container.SetSteamAccessTokenUnsafe(token, domainUri); + } + + + /// + /// Sets a Steam access token as a secure, HTTP-only cookie to the specified cookie container for the given domain. + /// + /// + /// This method does not perform validation on the input parameters and should only be used when + /// input values are trusted. The added cookie is marked as secure and HTTP-only, and its expiration is set + /// according to the token's expiration time. + /// + /// The cookie container to which the Steam access token cookie will be added. Cannot be null. + /// The Steam access token to add as a cookie. Must contain a valid signed token and expiration. + /// The URI of the domain for which the cookie will be set. Cannot be null. + public static void SetSteamAccessTokenUnsafe(this CookieContainer container, SteamAuthToken token, Uri domainUri) + { + container.Add(domainUri, new Cookie(ACCESS_COOKIE_NAME, token.SignedToken) { HttpOnly = true, Secure = true, @@ -257,7 +262,6 @@ public static class AdmissionHelper }); } - public static bool IsSteamCookie(Cookie cookie) { return cookie.Domain.Contains("steamcommunity.com") || cookie.Domain.Contains("steampowered.com") || diff --git a/src/SteamLibForked/Core/SteamDomains.cs b/src/SteamLibForked/Core/SteamDomains.cs index a74737c..5be37e7 100644 --- a/src/SteamLibForked/Core/SteamDomains.cs +++ b/src/SteamLibForked/Core/SteamDomains.cs @@ -1,49 +1,43 @@ -using System.Collections.ObjectModel; -using SteamLibForked.Models.Core; +using SteamLibForked.Models.Core; +using System.Collections.Immutable; namespace SteamLib.Core; public static class SteamDomains { public static IReadOnlyDictionary Domains { get; } = - new ReadOnlyDictionary( - new Dictionary - { - {SteamDomain.Community, SteamConstants.STEAM_COMMUNITY}, - {SteamDomain.Store, SteamConstants.STEAM_STORE}, - {SteamDomain.Help, SteamConstants.STEAM_HELP}, - {SteamDomain.TV, SteamConstants.STEAM_TV}, - {SteamDomain.Checkout, SteamConstants.STEAM_CHECKOUT}, - {SteamDomain.Login, SteamConstants.STEAM_LOGIN}, - {SteamDomain.API, SteamConstants.STEAM_API} - }); + new Dictionary + { + {SteamDomain.Community, SteamConstants.STEAM_COMMUNITY}, + {SteamDomain.Store, SteamConstants.STEAM_STORE}, + {SteamDomain.Help, SteamConstants.STEAM_HELP}, + {SteamDomain.TV, SteamConstants.STEAM_TV}, + {SteamDomain.Checkout, SteamConstants.STEAM_CHECKOUT}, + {SteamDomain.Login, SteamConstants.STEAM_LOGIN}, + {SteamDomain.API, SteamConstants.STEAM_API} + }.ToImmutableDictionary(); public static IReadOnlyDictionary DomainUris { get; } - = new ReadOnlyDictionary( - Domains.ToDictionary(x => x.Key, x => new Uri(x.Value)) - ); + = Domains + .ToDictionary(x => x.Key, x => new Uri(x.Value)) + .ToImmutableDictionary(); - public static IEnumerable AllDomains { get; } = - [ - SteamDomain.Community, - SteamDomain.Store, - SteamDomain.Help, - SteamDomain.TV, - SteamDomain.Checkout, - SteamDomain.Login, - SteamDomain.API - ]; - - public static IEnumerable AuthDomains { get; } = - [ - SteamDomain.Community, - SteamDomain.Store, - SteamDomain.Help, - SteamDomain.TV, - SteamDomain.Checkout - ]; + /// + /// All known public Steam domains. + /// + public static IEnumerable AllDomains { get; } = ImmutableHashSet.Create(SteamDomain.Community, + SteamDomain.Store, SteamDomain.Help, SteamDomain.TV, SteamDomain.Checkout, SteamDomain.Login, SteamDomain.API); + /// + /// Steam web domains that participate in the standard login authorization flow. + /// + /// These domains use the web:* audience format and receive + /// authentication cookies/tokens during session initialization. + /// + /// + public static IReadOnlySet WebDomains { get; } = ImmutableHashSet.Create(SteamDomain.Community, + SteamDomain.Store, SteamDomain.Help, SteamDomain.TV, SteamDomain.Checkout); public static Uri GetDomainUri(SteamDomain domain) { diff --git a/src/SteamLibForked/Models/Core/Session/MobileSessionData.cs b/src/SteamLibForked/Models/Core/Session/MobileSessionData.cs index 5c228fc..e02b88c 100644 --- a/src/SteamLibForked/Models/Core/Session/MobileSessionData.cs +++ b/src/SteamLibForked/Models/Core/Session/MobileSessionData.cs @@ -1,13 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using Newtonsoft.Json; +using Newtonsoft.Json; +using SteamLib.Core; using SteamLibForked.Abstractions; using SteamLibForked.Models.Core; +using System.Diagnostics.CodeAnalysis; namespace SteamLibForked.Models.Session; //WARNING: Any changes here should be reflected in MafileSerializer.cs -public sealed class MobileSessionData : SessionData, IMobileSessionData +public class MobileSessionData : SessionData, IMobileSessionData { public SteamAuthToken? MobileToken { get; private set; } @@ -31,26 +32,39 @@ public sealed class MobileSessionData : SessionData, IMobileSessionData return MobileToken; } + public override SteamAuthToken? GetToken(SteamDomain domain) + { + var isWeb = SteamDomains.WebDomains.Contains(domain); + if (isWeb) + { + // Mobile-issued tokens usually contain the "web" audience, + // so we assume they can also be used for all web:* domains. + // See: SteamAuthToken 'TODO' for more details + return MobileToken ?? base.GetToken(domain); + } + return base.GetToken(domain); + } + [MemberNotNull(nameof(MobileToken))] - public void SetMobileToken(SteamAuthToken token) + public virtual void SetMobileToken(SteamAuthToken token) { if (token.Type != SteamAccessTokenType.Mobile) throw new ArgumentException("Token must be of type MobileAccess", nameof(token)) { - Data = {{"ActualType", token.Type}} + Data = { { "ActualType", token.Type } } }; MobileToken = token; } - public override MobileSessionData Clone() - { - return (MobileSessionData) ((ISessionData) this).Clone(); - } - object ICloneable.Clone() { return new MobileSessionData(SessionId, SteamId, RefreshToken, MobileToken, Tokens); } + + public override MobileSessionData Clone() + { + return (MobileSessionData)((ISessionData)this).Clone(); + } } \ No newline at end of file diff --git a/src/SteamLibForked/Web/ClientBuilder.cs b/src/SteamLibForked/Web/ClientBuilder.cs index 9118d7a..28640fd 100644 --- a/src/SteamLibForked/Web/ClientBuilder.cs +++ b/src/SteamLibForked/Web/ClientBuilder.cs @@ -31,7 +31,7 @@ public static class ClientBuilder } else { - container.SetSteamMobileCookiesWithMobileToken(sessionData); + container.SetSteamMobileCookies(sessionData); } //Nebula tweak: From b5d3b4a8af49922474d05bf12862bd671f7ada7d Mon Sep 17 00:00:00 2001 From: achiez Date: Thu, 14 May 2026 16:44:14 +0300 Subject: [PATCH 02/13] feat(proxy): add bulk assignment and proxy usage stats - Implement bulk proxy assignment for accounts with flexible input (login:proxy, login:ID, or login) - Add UI for mass assignment with counters and behavior selection for unspecified proxies - Show assigned account count badges in proxy list for better visibility - Enable quick assignment of a free proxy to an account - Ensure fast and consistent proxy assignment state via in-memory cache - Perform ReSharper cleanup --- src/NebulaAuth/Converters/Converters.xaml | 1 + .../Converters/ProxyAccountCountConverter.cs | 25 ++ .../dialogs.proxymanager.loc.json | 110 ++++++ .../Localization/mainwindow.loc.json | 10 + src/NebulaAuth/MainWindow.xaml | 3 + src/NebulaAuth/Model/ProxyAssignmentCache.cs | 79 ++++ src/NebulaAuth/Model/Shell.cs | 1 + src/NebulaAuth/Theme/Brushes.xaml | 3 + src/NebulaAuth/View/ProxyManagerView.xaml | 364 ++++++++++++------ src/NebulaAuth/ViewModel/MainVM.cs | 2 +- src/NebulaAuth/ViewModel/MainVM_File.cs | 10 +- src/NebulaAuth/ViewModel/MainVM_Proxy.cs | 38 +- .../Other/ProxyManagerVM.Assignment.cs | 187 +++++++++ src/SteamLibForked/Core/SteamDomains.cs | 4 +- .../Models/Core/Session/MobileSessionData.cs | 9 +- .../MafileSerialization/LegacyMafile.cs | 9 +- 16 files changed, 710 insertions(+), 145 deletions(-) create mode 100644 src/NebulaAuth/Converters/ProxyAccountCountConverter.cs create mode 100644 src/NebulaAuth/Model/ProxyAssignmentCache.cs create mode 100644 src/NebulaAuth/ViewModel/Other/ProxyManagerVM.Assignment.cs diff --git a/src/NebulaAuth/Converters/Converters.xaml b/src/NebulaAuth/Converters/Converters.xaml index 97888f1..8ef5bd7 100644 --- a/src/NebulaAuth/Converters/Converters.xaml +++ b/src/NebulaAuth/Converters/Converters.xaml @@ -15,6 +15,7 @@ + diff --git a/src/NebulaAuth/Converters/ProxyAccountCountConverter.cs b/src/NebulaAuth/Converters/ProxyAccountCountConverter.cs new file mode 100644 index 0000000..f39d5b4 --- /dev/null +++ b/src/NebulaAuth/Converters/ProxyAccountCountConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NebulaAuth.Model; + +namespace NebulaAuth.Converters; + +/// +/// Converts a proxy ID (int) to the number of accounts assigned to it. +/// Returns empty string when count is zero so the badge is invisible. +/// +public class ProxyAccountCountConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not int proxyId) return string.Empty; + var count = ProxyAssignmentCache.GetAccountCount(proxyId); + return count > 0 ? count.ToString() : string.Empty; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/src/NebulaAuth/Localization/dialogs.proxymanager.loc.json b/src/NebulaAuth/Localization/dialogs.proxymanager.loc.json index 07193f8..04a7baf 100644 --- a/src/NebulaAuth/Localization/dialogs.proxymanager.loc.json +++ b/src/NebulaAuth/Localization/dialogs.proxymanager.loc.json @@ -40,6 +40,26 @@ "es": "El proxy tiene un formato incorrecto", "tr": "Proxy yanlış formatta", "kk": "Прокси қате форматта" + }, + "AssignmentSuccess": { + "ru": "Прокси назначены: {0}", + "en": "Assigned: {0}", + "zh": "已分配:{0}", + "uk": "Проксі призначено: {0}", + "fr": "Attribués: {0}", + "es": "Asignados: {0}", + "tr": "Atandı: {0}", + "kk": "Тағайындалды: {0}" + }, + "AssignmentPartialSuccess": { + "ru": "Назначено: {0}, не найдено: {1}, ошибки: {2}", + "en": "Assigned: {0}, not found: {1}, errors: {2}", + "zh": "已分配:{0},未找到:{1},错误:{2}", + "uk": "Призначено: {0}, не знайдено: {1}, помилки: {2}", + "fr": "Attribués: {0}, non trouvés: {1}, erreurs: {2}", + "es": "Asignados: {0}, no encontrados: {1}, errores: {2}", + "tr": "Atandı: {0}, bulunamadı: {1}, hatalar: {2}", + "kk": "Тағайындалды: {0}, табылмады: {1}, қателер: {2}" } } }, @@ -93,6 +113,96 @@ "es": "Mostrar credenciales", "tr": "Kimlik bilgilerini göster", "kk": "Тіркелгі деректерін көрсету" + }, + "TabManager": { + "en": "Manager", + "ru": "Менеджер", + "zh": "管理", + "uk": "Менеджер", + "fr": "Gestionnaire", + "es": "Administrador", + "tr": "Yönetici", + "kk": "Менеджер" + }, + "TabAssignment": { + "en": "Assignment", + "ru": "Назначение", + "zh": "分配", + "uk": "Призначення", + "fr": "Attribution", + "es": "Asignación", + "tr": "Atama", + "kk": "Тағайындау" + }, + "TotalAccounts": { + "en": "Accounts", + "ru": "Аккаунтов", + "zh": "账户", + "uk": "Акаунтів", + "fr": "Comptes", + "es": "Cuentas", + "tr": "Hesaplar", + "kk": "Аккаунттар" + }, + "TotalProxies": { + "en": "Proxies", + "ru": "Прокси", + "zh": "代理", + "uk": "Проксі", + "fr": "Proxies", + "es": "Proxies", + "tr": "Proxy'ler", + "kk": "Проксилер" + }, + "AssignedProxies": { + "en": "Assigned", + "ru": "Назначено", + "zh": "已分配", + "uk": "Призначено", + "fr": "Attribués", + "es": "Asignados", + "tr": "Atandı", + "kk": "Тағайындалды" + }, + "UnspecifiedBehavior": { + "en": "Unspecified proxy:", + "ru": "Не указанный прокси:", + "zh": "未指定代理:", + "uk": "Не вказаний проксі:", + "fr": "Proxy non spécifié:", + "es": "Proxy no especificado:", + "tr": "Belirtilmemiş proxy:", + "kk": "Көрсетілмеген прокси:" + }, + "UnspecifiedRandom": { + "en": "Assign random", + "ru": "Назначить случайный", + "zh": "随机分配", + "uk": "Призначити випадковий", + "fr": "Attribuer aléatoirement", + "es": "Asignar aleatorio", + "tr": "Rastgele ata", + "kk": "Кездейсоқ тағайындау" + }, + "UnspecifiedRemove": { + "en": "Remove proxy", + "ru": "Удалить прокси", + "zh": "删除代理", + "uk": "Видалити проксі", + "fr": "Supprimer le proxy", + "es": "Eliminar proxy", + "tr": "Proxy'yi kaldır", + "kk": "Проксиді жою" + }, + "ApplyAssignment": { + "en": "Apply", + "ru": "Применить", + "zh": "应用", + "uk": "Застосувати", + "fr": "Appliquer", + "es": "Aplicar", + "tr": "Uygula", + "kk": "Қолдану" } } } \ No newline at end of file diff --git a/src/NebulaAuth/Localization/mainwindow.loc.json b/src/NebulaAuth/Localization/mainwindow.loc.json index c8b57e8..ca22364 100644 --- a/src/NebulaAuth/Localization/mainwindow.loc.json +++ b/src/NebulaAuth/Localization/mainwindow.loc.json @@ -904,6 +904,16 @@ "es": "Desvincular proxy", "tr": "Proxy bağlantısını kaldır", "kk": "Проксиді ажырату" + }, + "AssignFreeProxy": { + "en": "Assign free proxy", + "ru": "Назначить свободный прокси", + "zh": "分配空闲代理", + "uk": "Призначити вільний проксі", + "fr": "Attribuer un proxy libre", + "es": "Asignar proxy libre", + "tr": "Serbest proxy ata", + "kk": "Бос проксиді тағайындау" } }, "Proxy": { diff --git a/src/NebulaAuth/MainWindow.xaml b/src/NebulaAuth/MainWindow.xaml index 6eb2983..47654ed 100644 --- a/src/NebulaAuth/MainWindow.xaml +++ b/src/NebulaAuth/MainWindow.xaml @@ -420,6 +420,9 @@ + diff --git a/src/NebulaAuth/Model/ProxyAssignmentCache.cs b/src/NebulaAuth/Model/ProxyAssignmentCache.cs new file mode 100644 index 0000000..0a02f32 --- /dev/null +++ b/src/NebulaAuth/Model/ProxyAssignmentCache.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AchiesUtilities.Collections; +using AchiesUtilities.Web.Proxy; +using NebulaAuth.Model.Entities; + +namespace NebulaAuth.Model; + +/// +/// In-memory cache tracking which proxy IDs are currently assigned to accounts. +/// Provides fast lookup for free proxy selection without iterating all mafiles. +/// Initialized at startup and kept in sync whenever proxy assignments change. +/// +public static class ProxyAssignmentCache +{ + private static readonly Dictionary> _proxyToAccounts = new(); + + public static void Initialize(IEnumerable mafiles) + { + _proxyToAccounts.Clear(); + foreach (var maf in mafiles) + { + if (maf.Proxy != null) + AddInternal(maf.Proxy.Id, maf.AccountName); + } + } + + /// + /// Call whenever a proxy assignment on a mafile changes and is persisted to disk. + /// + public static void UpdateAssignment(string? accountName, int? oldProxyId, int? newProxyId) + { + if (accountName == null) return; + if (oldProxyId.HasValue) RemoveInternal(oldProxyId.Value, accountName); + if (newProxyId.HasValue) AddInternal(newProxyId.Value, accountName); + } + + public static bool IsProxyFree(int proxyId) + { + return !_proxyToAccounts.TryGetValue(proxyId, out var set) || set.Count == 0; + } + + public static int GetAccountCount(int proxyId) + { + return _proxyToAccounts.TryGetValue(proxyId, out var set) ? set.Count : 0; + } + + /// + /// Returns a random free proxy (not the default proxy), or null if none available. + /// + public static KeyValuePair? GetRandomFreeProxy( + ObservableDictionary proxies, + int? excludeDefaultProxyId) + { + var free = proxies + .Where(kvp => kvp.Key != excludeDefaultProxyId && IsProxyFree(kvp.Key)) + .ToList(); + if (free.Count == 0) return null; + return free[Random.Shared.Next(free.Count)]; + } + + private static void AddInternal(int proxyId, string accountName) + { + if (!_proxyToAccounts.TryGetValue(proxyId, out var set)) + { + set = new HashSet(StringComparer.OrdinalIgnoreCase); + _proxyToAccounts[proxyId] = set; + } + + set.Add(accountName); + } + + private static void RemoveInternal(int proxyId, string accountName) + { + if (_proxyToAccounts.TryGetValue(proxyId, out var set)) + set.Remove(accountName); + } +} \ No newline at end of file diff --git a/src/NebulaAuth/Model/Shell.cs b/src/NebulaAuth/Model/Shell.cs index 7a74a5d..f2ce3e5 100644 --- a/src/NebulaAuth/Model/Shell.cs +++ b/src/NebulaAuth/Model/Shell.cs @@ -45,6 +45,7 @@ public static class Shell var threads = Environment.ProcessorCount > 0 ? Environment.ProcessorCount : 1; await Storage.Initialize(threads); + ProxyAssignmentCache.Initialize(Storage.MaFiles); MAACStorage.Initialize(); MafileExporterStorage.Initialize(); diff --git a/src/NebulaAuth/Theme/Brushes.xaml b/src/NebulaAuth/Theme/Brushes.xaml index a75f635..5c13e96 100644 --- a/src/NebulaAuth/Theme/Brushes.xaml +++ b/src/NebulaAuth/Theme/Brushes.xaml @@ -89,4 +89,7 @@ Color="{StaticResource SecondaryColor}" /> + + \ No newline at end of file diff --git a/src/NebulaAuth/View/ProxyManagerView.xaml b/src/NebulaAuth/View/ProxyManagerView.xaml index 6eea83f..6dfadf9 100644 --- a/src/NebulaAuth/View/ProxyManagerView.xaml +++ b/src/NebulaAuth/View/ProxyManagerView.xaml @@ -20,12 +20,12 @@ FontSize="16"> - - + + @@ -45,90 +45,41 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - + + + + + + + - - + + + - + - - - + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + - + + + + + + + + + + + + - - - + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - - - - - - - - - - - - + + @@ -233,8 +213,8 @@ - - - - + + Grid.Row="2" Margin="20" Content="{Binding CurrentStep}" /> \ No newline at end of file diff --git a/src/NebulaAuth/View/ProxyManagerView.xaml b/src/NebulaAuth/View/ProxyManagerView.xaml index 0497079..8266353 100644 --- a/src/NebulaAuth/View/ProxyManagerView.xaml +++ b/src/NebulaAuth/View/ProxyManagerView.xaml @@ -20,34 +20,14 @@ FontSize="16"> - - - - - - - - - - - - - - + - diff --git a/src/NebulaAuth/View/SetAccountPasswordsView.xaml b/src/NebulaAuth/View/SetAccountPasswordsView.xaml index 2feda85..2f72e30 100644 --- a/src/NebulaAuth/View/SetAccountPasswordsView.xaml +++ b/src/NebulaAuth/View/SetAccountPasswordsView.xaml @@ -20,7 +20,6 @@ FontSize="16"> - @@ -29,36 +28,10 @@ - - - - - - - - - - - - - - - - + - + @@ -109,7 +82,7 @@ - - - - - + + Style="{StaticResource MaterialDesignUniformTabControl}" Grid.Row="1"> Date: Fri, 19 Jun 2026 21:18:34 +0300 Subject: [PATCH 09/13] feat(export): add zip export, export-all, and login:password input parsing --- .../Localization/dialogs.export.loc.json | 36 +++++++--- .../MafileExport/MafileExportTemplate.cs | 1 + .../Model/MafileExport/MafileExporter.cs | 71 +++++++++++++------ src/NebulaAuth/View/MafileExporterView.xaml | 10 ++- .../ViewModel/Other/MafileExporterVM.cs | 52 ++++++++------ 5 files changed, 118 insertions(+), 52 deletions(-) diff --git a/src/NebulaAuth/Localization/dialogs.export.loc.json b/src/NebulaAuth/Localization/dialogs.export.loc.json index fe35dc2..787b67c 100644 --- a/src/NebulaAuth/Localization/dialogs.export.loc.json +++ b/src/NebulaAuth/Localization/dialogs.export.loc.json @@ -196,6 +196,16 @@ "es": "Grupo", "tr": "Grup", "kk": "Топ" + }, + "ExportToZip": { + "ru": "Экспортировать в ZIP архив", + "en": "Export to ZIP archive", + "uk": "Експортувати в ZIP архів", + "fr": "Exporter en archive ZIP", + "zh": "导出为 ZIP 压缩包", + "es": "Exportar a archivo ZIP", + "tr": "ZIP arşivine aktar", + "kk": "ZIP мұрағатқа экспорттау" } }, "Tooltips": { @@ -288,17 +298,27 @@ "es": "Incluir datos de grupo compatibles con Nebula.", "tr": "Nebula uyumlu grup verilerini dahil et.", "kk": "Nebula үйлесімді топ деректерін қосу." + }, + "ExportToZip": { + "ru": "Экспортировать в ZIP архив вместо папки. Архив будет сохранён в выбранной директории с именем вида N_timestamp.zip.", + "en": "Export to a ZIP archive instead of a folder. The archive will be saved in the selected directory as N_timestamp.zip.", + "uk": "Експортувати в ZIP архів замість папки. Архів буде збережено у вибраній директорії з назвою N_timestamp.zip.", + "fr": "Exporter dans une archive ZIP plutôt que dans un dossier. L'archive sera enregistrée dans le répertoire sélectionné sous le nom N_timestamp.zip.", + "zh": "导出为 ZIP 压缩包而非文件夹。压缩包将保存在所选目录中,名称格式为 N_timestamp.zip。", + "es": "Exportar a un archivo ZIP en lugar de una carpeta. El archivo se guardará en el directorio seleccionado como N_timestamp.zip.", + "tr": "Klasör yerine ZIP arşivine aktar. Arşiv, seçilen dizine N_timestamp.zip adıyla kaydedilecektir.", + "kk": "Қалта орнына ZIP мұрағатына экспорттау. Мұрағат таңдалған каталогта N_timestamp.zip атауымен сақталады." } }, "ExportAccountsPlaceholder": { - "ru": "Введите логины аккаунтов или SteamID (по одному на строку) для экспорта в выбранную директорию.", - "en": "Enter account logins or SteamIDs (one per line) to export to the selected directory.", - "uk": "Введіть логіни акаунтів або SteamID (по одному на рядок) для експорту у вибрану директорію.", - "fr": "Entrez les identifiants de compte ou les SteamID (un par ligne) pour exporter vers le répertoire sélectionné.", - "zh": "输入要导出到所选目录的账户登录名或 SteamID(每行一个)。", - "es": "Introduce los logins de las cuentas o SteamID (uno por línea) para exportarlos al directorio seleccionado.", - "tr": "Seçilen dizine dışa aktarmak için hesap girişlerini veya SteamID’leri (her satıra bir tane) girin.", - "kk": "Таңдалған каталогқа экспорттау үшін аккаунт логиндерін немесе SteamID (әр жолға біреуден) енгізіңіз." + "ru": "Введите логины или SteamID (по одному на строку). Поддерживается формат логин:пароль — пароль будет проигнорирован. Оставьте пустым для экспорта всех аккаунтов.", + "en": "Enter logins or SteamIDs (one per line). Supports login:password format — the password will be ignored. Leave empty to export all accounts.", + "uk": "Введіть логіни або SteamID (по одному на рядок). Підтримується формат логін:пароль — пароль буде проігноровано. Залиште порожнім для експорту всіх акаунтів.", + "fr": "Entrez les identifiants ou SteamID (un par ligne). Supporte le format login:mot_de_passe — le mot de passe sera ignoré. Laissez vide pour exporter tous les comptes.", + "zh": "输入登录名或 SteamID(每行一个)。支持 login:password 格式——密码将被忽略。留空则导出所有账户。", + "es": "Introduce logins o SteamID (uno por línea). Soporta el formato login:contraseña — la contraseña será ignorada. Déjalo vacío para exportar todas las cuentas.", + "tr": "Girişleri veya SteamID’leri girin (her satıra bir tane). login:şifre formatı desteklenir — şifre yok sayılır. Tüm hesapları dışa aktarmak için boş bırakın.", + "kk": "Логиндерді немесе SteamID (әр жолға біреуден) енгізіңіз. логин:құпиясөз форматы қолданылады — құпиясөз еленбейді. Барлық аккаунттарды экспорттау үшін бос қалдырыңыз." } } } \ No newline at end of file diff --git a/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs b/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs index 3a5311f..01459c5 100644 --- a/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs +++ b/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs @@ -13,4 +13,5 @@ public class MafileExportTemplate public bool IncludeNebulaPassword { get; set; } public bool IncludeNebulaGroup { get; set; } public string? Path { get; set; } + public bool ExportToZip { get; set; } } \ No newline at end of file diff --git a/src/NebulaAuth/Model/MafileExport/MafileExporter.cs b/src/NebulaAuth/Model/MafileExport/MafileExporter.cs index 9774193..b808d6b 100644 --- a/src/NebulaAuth/Model/MafileExport/MafileExporter.cs +++ b/src/NebulaAuth/Model/MafileExport/MafileExporter.cs @@ -1,6 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; +using System.Text; using System.Threading.Tasks; using AchiesUtilities.Extensions; using NebulaAuth.Core; @@ -36,48 +39,79 @@ public static class MafileExporter return new ExportResult(LocManager.GetCodeBehindOrDefault("AccessDenied", "MafileExporter.AccessDenied")); } - var exported = new Dictionary(); var notFound = new List(); var conflict = new List(); + var toExport = new List<(string key, Mafile mafile)>(); foreach (var key in keys) { SteamId? steamId = null; if (SteamId64.TryParse(key, out var id64)) - { steamId = id64; - } var maf = Storage.MaFiles.FirstOrDefault(m => m.AccountName.EqualsIgnoreCase(key) || m.SteamId == steamId); if (maf != null) + toExport.Add((key, maf)); + else + notFound.Add(key); + } + + if (template.ExportToZip) + { + if (toExport.Count > 0) { - var fileName = await ExportMafile(template.Path, template, maf); - if (fileName == null) + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + var zipName = $"{toExport.Count}_{timestamp}.zip"; + var zipPath = Path.Combine(template.Path, zipName); + + await using var zipStream = File.Create(zipPath); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + var addedEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (key, maf) in toExport) + { + var (content, fileName) = BuildMafileContent(template, maf); + if (!addedEntries.Add(fileName)) + { + conflict.Add(key); + continue; + } + + var entry = archive.CreateEntry(fileName); + await using var entryStream = entry.Open(); + await using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + exported[maf] = fileName; + } + } + } + else + { + foreach (var (key, maf) in toExport) + { + var (content, fileName) = BuildMafileContent(template, maf); + var fullPath = Path.Combine(template.Path, fileName); + if (File.Exists(fullPath)) { conflict.Add(key); continue; } - exported[maf] = fileName; - } - else - { - notFound.Add(key); + await File.WriteAllTextAsync(fullPath, content); + exported[maf] = fullPath; } } return new ExportResult(exported, notFound, conflict); } - private static async Task ExportMafile(string path, MafileExportTemplate template, Mafile mafile) + private static (string content, string fileName) BuildMafileContent(MafileExportTemplate template, Mafile mafile) { - // We preserve SteamId even if other info is not included var session = template.IncludeSessionData ? mafile.SessionData : new MobileSessionData(null!, mafile.SteamId, default, null, tokens: null); - var serializeMaf = new Mafile { SharedSecret = template.IncludeSharedSecret ? mafile.SharedSecret : null!, @@ -102,14 +136,7 @@ public static class MafileExporter var serialized = NebulaSerializer.SerializeMafile(serializeMaf); var strategy = template.UseLoginAsMafileName ? MafileNamingStrategy.Login : MafileNamingStrategy.SteamId; var fileName = strategy.GetMafileName(serializeMaf); - var fullPath = Path.Combine(path, fileName); - if (File.Exists(fullPath)) - { - return null; - } - - await File.WriteAllTextAsync(fullPath, serialized); - return fullPath; + return (serialized, fileName); } private static bool PathIsValid(string path) diff --git a/src/NebulaAuth/View/MafileExporterView.xaml b/src/NebulaAuth/View/MafileExporterView.xaml index 3d4281d..38afeda 100644 --- a/src/NebulaAuth/View/MafileExporterView.xaml +++ b/src/NebulaAuth/View/MafileExporterView.xaml @@ -9,8 +9,8 @@ mc:Ignorable="d" TextElement.Foreground="{DynamicResource BaseContentBrush}" FontFamily="{materialDesign:MaterialDesignFont}" - MinWidth="400" - MaxWidth="400" + MinWidth="500" + MaxWidth="500" Background="{DynamicResource WindowBackground}" d:DataContext="{d:DesignInstance other:MafileExporterVM}" d:Background="#141119" @@ -113,6 +113,12 @@ IsEnabled="{Binding CurrentTemplate, Converter={StaticResource NullableToBooleanConverter}}"> + + diff --git a/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs b/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs index 97e2228..5d9a8d4 100644 --- a/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs +++ b/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using NebulaAuth.Core; using NebulaAuth.Model.MafileExport; +using NebulaAuth.Model.Mafiles; namespace NebulaAuth.ViewModel.Other; @@ -24,8 +25,7 @@ public partial class MafileExporterVM : ObservableObject public ObservableCollection Templates { get; } - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ExportCommand))] - private string? _accountsText; + [ObservableProperty] private string? _accountsText; private string? _cachedName; private MafileExportTemplateVM? _currentTemplate; @@ -160,6 +160,7 @@ public partial class MafileExporterVM : ObservableObject IncludeNebulaProxy = true, IncludeNebulaPassword = true, IncludeNebulaGroup = true, + ExportToZip = false, Path = null }; MafileExporterStorage.AddTemplate(template); @@ -211,21 +212,26 @@ public partial class MafileExporterVM : ObservableObject } } - [RelayCommand(CanExecute = nameof(ExportCanExecute))] + [RelayCommand(CanExecute = nameof(CurrentTemplateNotNull))] private async Task Export() { - var lines = AccountsText; var template = CurrentTemplate; - if (string.IsNullOrWhiteSpace(lines) || template == null) - { - return; - } + if (template == null) return; - var split = Regex - .Split(lines, "\r\n|\r|\n") - .Select(x => x.Trim()) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .ToArray(); + string[] split; + if (string.IsNullOrWhiteSpace(AccountsText)) + { + split = Storage.MaFiles.Select(m => m.AccountName).ToArray(); + } + else + { + split = Regex + .Split(AccountsText, "\r\n|\r|\n") + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(ExtractLogin) + .ToArray(); + } ResetHintText(); ExportResult res; @@ -268,6 +274,14 @@ public partial class MafileExporterVM : ObservableObject AccountsText = text; } + // Strips the password part from "login:password" input lines. + // SteamId64 values contain no colon, so any colon signals the login:password format. + private static string ExtractLogin(string line) + { + var idx = line.IndexOf(':'); + return idx > 0 ? line[..idx] : line; + } + private void SetHintText(string? text, HintBoxSeverity severity = HintBoxSeverity.Error) { HintText = text; @@ -281,12 +295,6 @@ public partial class MafileExporterVM : ObservableObject #region CanExecute - private bool ExportCanExecute() - { - return CurrentTemplateNotNull() && !string.IsNullOrWhiteSpace(AccountsText); - } - - private bool CurrentTemplateNotNull() { return CurrentTemplate != null; @@ -307,6 +315,7 @@ public partial class MafileExporterVM : ObservableObject public partial class MafileExportTemplateVM : ObservableObject { + [ObservableProperty] private bool _exportToZip; [ObservableProperty] private bool _includeIdentitySecret; [ObservableProperty] private bool _includeNebulaGroup; [ObservableProperty] private bool _includeNebulaPassword; @@ -334,6 +343,7 @@ public partial class MafileExportTemplateVM : ObservableObject IncludeNebulaProxy = x.IncludeNebulaProxy, IncludeNebulaPassword = x.IncludeNebulaPassword, IncludeNebulaGroup = x.IncludeNebulaGroup, + ExportToZip = x.ExportToZip, Path = x.Path }; } @@ -352,6 +362,7 @@ public partial class MafileExportTemplateVM : ObservableObject IncludeNebulaProxy = IncludeNebulaProxy, IncludeNebulaPassword = IncludeNebulaPassword, IncludeNebulaGroup = IncludeNebulaGroup, + ExportToZip = ExportToZip, Path = Path }; } @@ -368,6 +379,7 @@ public partial class MafileExportTemplateVM : ObservableObject model.IncludeNebulaProxy = IncludeNebulaProxy; model.IncludeNebulaPassword = IncludeNebulaPassword; model.IncludeNebulaGroup = IncludeNebulaGroup; + model.ExportToZip = ExportToZip; model.Path = Path; } } \ No newline at end of file From c39660fc55e567564a9ea16657f8983ea6f6f34d Mon Sep 17 00:00:00 2001 From: achiez Date: Fri, 19 Jun 2026 22:05:49 +0300 Subject: [PATCH 10/13] feat(session): show LoginAgainDialog on permanent session expiry - Replace snackbar with interactive LoginAgainDialog when session cannot be recovered automatically on user-initiated actions - Show proxy warning hint in dialog when no proxy assigned to account or set as default - Pass allowInteractiveLogin: false from MAACRequestHandler to skip dialog in automated MAAC confirmations --- src/NebulaAuth/Components/HintBox.xaml.cs | 5 +++ src/NebulaAuth/Core/DialogsController.cs | 6 ++-- .../Localization/dialogs.loginagain.loc.json | 10 ++++++ .../Model/MAAC/MAACRequestHandler.cs | 2 +- .../Model/SessionHandler/SessionHandler.cs | 35 +++++++++++++++++-- src/NebulaAuth/Utility/ExceptionHandler.cs | 2 +- .../View/Dialogs/LoginAgainDialog.xaml | 14 +++++--- .../ViewModel/Other/LoginAgainVM.cs | 2 ++ 8 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/NebulaAuth/Components/HintBox.xaml.cs b/src/NebulaAuth/Components/HintBox.xaml.cs index 38d84d1..8943147 100644 --- a/src/NebulaAuth/Components/HintBox.xaml.cs +++ b/src/NebulaAuth/Components/HintBox.xaml.cs @@ -77,6 +77,10 @@ public partial class HintBox : UserControl IconKind = PackIconKind.ErrorOutline; IconBrush = (Brush) Application.Current.FindResource("ErrorBrush")!; break; + case HintBoxSeverity.Warning: + IconKind = PackIconKind.WarningOutline; + IconBrush = (Brush) Application.Current.FindResource("WarningBrush")!; + break; default: IconKind = PackIconKind.InfoCircleOutline; IconBrush = (Brush) Application.Current.FindResource("InfoBrush")!; @@ -94,5 +98,6 @@ public partial class HintBox : UserControl public enum HintBoxSeverity { Info, + Warning, Error } \ No newline at end of file diff --git a/src/NebulaAuth/Core/DialogsController.cs b/src/NebulaAuth/Core/DialogsController.cs index 2e25ba8..2fe5ae8 100644 --- a/src/NebulaAuth/Core/DialogsController.cs +++ b/src/NebulaAuth/Core/DialogsController.cs @@ -15,13 +15,15 @@ namespace NebulaAuth.Core; public static class DialogsController { - public static async Task ShowLoginAgainDialog(string username, string? currentPassword = null) + public static async Task ShowLoginAgainDialog(string username, string? currentPassword = null, + bool showNoProxyWarning = false) { var vm = new LoginAgainVM { UserName = username, Password = currentPassword ?? string.Empty, - SavePassword = PHandler.IsPasswordSet + SavePassword = PHandler.IsPasswordSet, + ShowNoProxyWarning = showNoProxyWarning }; var content = new LoginAgainDialog { diff --git a/src/NebulaAuth/Localization/dialogs.loginagain.loc.json b/src/NebulaAuth/Localization/dialogs.loginagain.loc.json index c8cade7..20ace34 100644 --- a/src/NebulaAuth/Localization/dialogs.loginagain.loc.json +++ b/src/NebulaAuth/Localization/dialogs.loginagain.loc.json @@ -79,6 +79,16 @@ "es": "Pulsa 'DEL' para eliminar", "tr": "Silmek için 'DEL' tuşuna bas", "kk": "Жою үшін 'DEL' пернесін басыңыз" + }, + "NoProxyWarning": { + "en": "No proxy is assigned", + "ru": "Прокси не установлен", + "zh": "未分配代理", + "uk": "Проксі не призначено", + "fr": "Aucun proxy assigné", + "es": "No hay ningún proxy asignado", + "tr": "Proxy atanmamış", + "kk": "Прокси тағайындалмаған" } } } \ No newline at end of file diff --git a/src/NebulaAuth/Model/MAAC/MAACRequestHandler.cs b/src/NebulaAuth/Model/MAAC/MAACRequestHandler.cs index bf3f5e6..68e71a3 100644 --- a/src/NebulaAuth/Model/MAAC/MAACRequestHandler.cs +++ b/src/NebulaAuth/Model/MAAC/MAACRequestHandler.cs @@ -82,7 +82,7 @@ public static class MAACRequestHandler { return withSessionHandler ? Result.Success(await SessionHandler.Handle(req, client.Mafile, client.Chp(), - GetTimerPrefix(client.Mafile))) + GetTimerPrefix(client.Mafile), false)) : Result.Success(await req()); } catch (SessionInvalidException ex) diff --git a/src/NebulaAuth/Model/SessionHandler/SessionHandler.cs b/src/NebulaAuth/Model/SessionHandler/SessionHandler.cs index b1c9979..46fa778 100644 --- a/src/NebulaAuth/Model/SessionHandler/SessionHandler.cs +++ b/src/NebulaAuth/Model/SessionHandler/SessionHandler.cs @@ -6,7 +6,9 @@ using AchiesUtilities.Web.Models; using MaterialDesignThemes.Wpf; using NebulaAuth.Core; using NebulaAuth.Model.Entities; +using NebulaAuth.Utility; using NebulaAuth.View.Dialogs; +using NebulaAuth.ViewModel.Other; using SteamLib.Exceptions.Authorization; namespace NebulaAuth.Model; @@ -16,13 +18,13 @@ public static partial class SessionHandler private static readonly SemaphoreSlim Semaphore = new(1, 1); public static async Task Handle(Func> func, Mafile mafile, - HttpClientHandlerPair? chp = null, string? snackbarPrefix = null) + HttpClientHandlerPair? chp = null, string? snackbarPrefix = null, bool allowInteractiveLogin = true) { chp ??= MaClient.GetHttpClientHandlerPair(mafile); await Semaphore.WaitAsync(); try { - return await HandleInternal(func, chp.Value, mafile, snackbarPrefix); + return await HandleInternal(func, chp.Value, mafile, snackbarPrefix, allowInteractiveLogin); } finally { @@ -31,7 +33,7 @@ public static partial class SessionHandler } private static async Task HandleInternal(Func> func, HttpClientHandlerPair chp, Mafile mafile, - string? snackbarPrefix = null) + string? snackbarPrefix = null, bool allowInteractiveLogin = true) { using var scope = Shell.Logger.PushScopeProperty("Scope", "SessionHandler"); Exception? currentException = null; @@ -86,6 +88,32 @@ public static partial class SessionHandler } } + if (allowInteractiveLogin && !DialogHost.IsDialogOpen(null)) + { + var noProxy = mafile.Proxy == null && MaClient.DefaultProxy == null; + var currentPassword = GetPassword(mafile); + + Task dialogTask = null!; + Application.Current.Dispatcher.Invoke(() => + { + dialogTask = DialogsController.ShowLoginAgainDialog(mafile.AccountName, currentPassword, noProxy); + }); + + var vm = await dialogTask; + if (vm != null) + { + Shell.Logger.Info("User provided credentials interactively for {name}, attempting login", + mafile.AccountName); + var logged = await LoginAgainInternal(chp, mafile, vm.Password, vm.SavePassword); + if (logged) + { + Shell.Logger.Debug("Interactive login for {name} succeeded, retrying operation", + mafile.AccountName); + return await func(); + } + } + } + throw new SessionPermanentlyExpiredException(SessionPermanentlyExpiredException.SESSION_EXPIRED_MSG, currentException); } @@ -118,6 +146,7 @@ public static partial class SessionHandler { Shell.Logger.Debug(ex, "Failed to relogin mafile {name} {steamid}", mafile.AccountName, mafile.SessionData?.SteamId); + ExceptionHandler.Handle(ex); return false; } finally diff --git a/src/NebulaAuth/Utility/ExceptionHandler.cs b/src/NebulaAuth/Utility/ExceptionHandler.cs index 6198906..c0e6c0c 100644 --- a/src/NebulaAuth/Utility/ExceptionHandler.cs +++ b/src/NebulaAuth/Utility/ExceptionHandler.cs @@ -93,7 +93,7 @@ public static class ExceptionHandler } case LoginException e: { - return "LoginException".GetCodeBehindLocalization() + ": " + + return "LoginException".GetCodeBehindLocalization() + ErrorTranslatorHelper.TranslateLoginError(e.Error); } case UnsupportedAuthTypeException e: diff --git a/src/NebulaAuth/View/Dialogs/LoginAgainDialog.xaml b/src/NebulaAuth/View/Dialogs/LoginAgainDialog.xaml index 3689a47..f0d05bd 100644 --- a/src/NebulaAuth/View/Dialogs/LoginAgainDialog.xaml +++ b/src/NebulaAuth/View/Dialogs/LoginAgainDialog.xaml @@ -25,24 +25,30 @@ + - + - - + diff --git a/src/NebulaAuth/ViewModel/Other/LoginAgainVM.cs b/src/NebulaAuth/ViewModel/Other/LoginAgainVM.cs index 3698bd3..c4a5636 100644 --- a/src/NebulaAuth/ViewModel/Other/LoginAgainVM.cs +++ b/src/NebulaAuth/ViewModel/Other/LoginAgainVM.cs @@ -11,5 +11,7 @@ public partial class LoginAgainVM : ObservableObject [ObservableProperty] private bool _savePassword; + [ObservableProperty] private bool _showNoProxyWarning; + [ObservableProperty] private string _userName = null!; } \ No newline at end of file From ed5d3c2eda12e54656688ea00e45d6060ab922ef Mon Sep 17 00:00:00 2001 From: achiez Date: Fri, 19 Jun 2026 22:34:37 +0300 Subject: [PATCH 11/13] feat(links): fetch website and docs URLs from GitHub at startup - Add links.json to repo (alongside update.xml) with Website and Documentation URLs. - Add LinksManager to support link fetching on startup via raw.githubusercontent.com - Suppress TimeAligner failure instead of blocking app launch --- NebulaAuth/links.json | 4 +++ src/NebulaAuth/Core/LinksManager.cs | 33 +++++++++++++++++++ src/NebulaAuth/Model/RemoteLinks.cs | 7 ++++ src/NebulaAuth/Model/Shell.cs | 5 +-- src/NebulaAuth/View/LinksView.xaml | 17 ++++++---- src/NebulaAuth/View/LinksView.xaml.cs | 14 ++++++-- src/SteamLibForked/SteamMobile/TimeAligner.cs | 6 ++++ 7 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 NebulaAuth/links.json create mode 100644 src/NebulaAuth/Core/LinksManager.cs create mode 100644 src/NebulaAuth/Model/RemoteLinks.cs diff --git a/NebulaAuth/links.json b/NebulaAuth/links.json new file mode 100644 index 0000000..e337513 --- /dev/null +++ b/NebulaAuth/links.json @@ -0,0 +1,4 @@ +{ + "Website": "https://achiefy.pro/?nebula=true", + "Documentation": "https://achiefy-project.gitbook.io/nebulaauth" +} diff --git a/src/NebulaAuth/Core/LinksManager.cs b/src/NebulaAuth/Core/LinksManager.cs new file mode 100644 index 0000000..b37b857 --- /dev/null +++ b/src/NebulaAuth/Core/LinksManager.cs @@ -0,0 +1,33 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using NebulaAuth.Model; +using Newtonsoft.Json; + +namespace NebulaAuth.Core; + +public static class LinksManager +{ + private const string LINKS_URL = + "https://raw.githubusercontent.com/achiez/NebulaAuth-Steam-Desktop-Authenticator-by-Achies/master/NebulaAuth/links.json"; + + private static readonly HttpClient HttpClient = new(); + + public static string? WebsiteUrl { get; private set; } + public static string? DocumentationUrl { get; private set; } + + public static async Task FetchAsync() + { + try + { + var json = await HttpClient.GetStringAsync(LINKS_URL).ConfigureAwait(false); + var links = JsonConvert.DeserializeObject(json); + WebsiteUrl = links?.Website; + DocumentationUrl = links?.Documentation; + } + catch (Exception ex) + { + Shell.Logger.Debug(ex, "Failed to fetch remote links"); + } + } +} \ No newline at end of file diff --git a/src/NebulaAuth/Model/RemoteLinks.cs b/src/NebulaAuth/Model/RemoteLinks.cs new file mode 100644 index 0000000..0f50b92 --- /dev/null +++ b/src/NebulaAuth/Model/RemoteLinks.cs @@ -0,0 +1,7 @@ +namespace NebulaAuth.Model; + +public class RemoteLinks +{ + public string? Website { get; set; } + public string? Documentation { get; set; } +} \ No newline at end of file diff --git a/src/NebulaAuth/Model/Shell.cs b/src/NebulaAuth/Model/Shell.cs index f2ce3e5..f4e61dd 100644 --- a/src/NebulaAuth/Model/Shell.cs +++ b/src/NebulaAuth/Model/Shell.cs @@ -3,7 +3,6 @@ using System.IO; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NebulaAuth.Core; -using NebulaAuth.Model.Exceptions; using NebulaAuth.Model.MAAC; using NebulaAuth.Model.MafileExport; using NebulaAuth.Model.Mafiles; @@ -40,7 +39,8 @@ public static class Shell } catch (Exception ex) { - throw new CantAlignTimeException("", ex); + Logger.Error(ex, "Failed to align time with Steam"); + TimeAligner.SetTimeDifference(0); } var threads = Environment.ProcessorCount > 0 ? Environment.ProcessorCount : 1; @@ -48,6 +48,7 @@ public static class Shell ProxyAssignmentCache.Initialize(Storage.MaFiles); MAACStorage.Initialize(); MafileExporterStorage.Initialize(); + _ = LinksManager.FetchAsync(); ExtensionsLogger.LogDebug("Application started"); } diff --git a/src/NebulaAuth/View/LinksView.xaml b/src/NebulaAuth/View/LinksView.xaml index f859483..b737a22 100644 --- a/src/NebulaAuth/View/LinksView.xaml +++ b/src/NebulaAuth/View/LinksView.xaml @@ -8,7 +8,8 @@ d:DesignHeight="450" d:DesignWidth="800" TextElement.Foreground="{DynamicResource BaseContentBrush}" Background="Transparent" - RenderOptions.BitmapScalingMode="HighQuality"> + RenderOptions.BitmapScalingMode="HighQuality" + Loaded="LinksView_Loaded"> @@ -49,16 +50,20 @@ - -