From e002f15fb462b49d2c300fe2133b87918033712b Mon Sep 17 00:00:00 2001 From: Giribaldi_TTV Date: Mon, 10 Aug 2026 13:38:38 -0700 Subject: [PATCH 01/17] feat(tracker): store and switch verified org keys by EFT profile Introduce a versioned local TarkovTracker.org key store with guarded legacy recovery and corruption-aware persistence. Verify PVE, Regular PVP, and Seasonal keys against the org endpoint, bind each key to an exact EFT account/profile/mode identity, and activate only the matching key for the live session. Support assignment, reassignment and swaps, unbinding, removal, account and profile nicknames, protected identifier messages, and fail-closed unknown modes while preserving retired TarkovTracker.io isolation. --- TarkovMonitor/App.config | 12 + .../Blazor/Components/MessageBoard.razor | 89 +- .../Pages/RawLogs/ForceReadDialog.razor | 3 +- TarkovMonitor/GameWatcher.cs | 204 +- TarkovMonitor/MainBlazorUI.cs | 146 +- TarkovMonitor/MessageLog.cs | 66 +- TarkovMonitor/MonitorMessage.cs | 100 +- TarkovMonitor/Properties/Settings.Designer.cs | 48 + TarkovMonitor/Properties/Settings.settings | 12 + TarkovMonitor/TarkovTracker.cs | 2104 +++++++++++++++-- TarkovMonitor/TarkovTrackerOrgStore.cs | 913 +++++++ 11 files changed, 3418 insertions(+), 279 deletions(-) create mode 100644 TarkovMonitor/TarkovTrackerOrgStore.cs diff --git a/TarkovMonitor/App.config b/TarkovMonitor/App.config index 9eb8272..19fd591 100644 --- a/TarkovMonitor/App.config +++ b/TarkovMonitor/App.config @@ -73,6 +73,18 @@ {} + + {} + + + {} + + + {"version":4,"keys":[],"accounts":[],"profiles":[]} + + + + 00:07:10 diff --git a/TarkovMonitor/Blazor/Components/MessageBoard.razor b/TarkovMonitor/Blazor/Components/MessageBoard.razor index 6472ebf..fa36605 100644 --- a/TarkovMonitor/Blazor/Components/MessageBoard.razor +++ b/TarkovMonitor/Blazor/Components/MessageBoard.razor @@ -1,28 +1,61 @@ @using Humanizer; @using System.Diagnostics; @using System.Threading +@implements IDisposable @inject MessageLog messageLog @inject IDialogService DialogService @inject LocalizationService LocalizationService +@inject NavigationManager NavManager - @if (messageLog.Messages.Count == 0) + @if (messages.Count == 0) { @LocalizationService.GetString("ThereArentAnyMessagesYet") }else{ - @foreach (MonitorMessage message in messageLog.Messages.ToList()) + @foreach (MonitorMessage message in messages) { -
-
- -
+
+
+ +
- - @message.Message - +
+ @if (string.IsNullOrWhiteSpace(message.LinkText)) + { + @message.Message + } + else if (message.Message.Contains(message.LinkText, StringComparison.Ordinal)) + { + var linkStart = message.Message.IndexOf(message.LinkText, StringComparison.Ordinal); + var linkEnd = linkStart + message.LinkText.Length; + @(message.Message[..linkStart])@message.LinkText@(message.Message[linkEnd..]) + } + else + { + @message.Message + @message.LinkText + } + @if (message.ProtectedValues.Count > 0) + { +
+ @foreach (var protectedValue in message.ProtectedValues) + { + + @protectedValue.Label + + + + + + } +
+ } +
- @foreach (MonitorMessageSelect select in message.Selects) + @foreach (MonitorMessageSelect select in message.Selects.GetSnapshot()) { @@ -32,7 +65,7 @@ } } - @foreach (MonitorMessageButton button in message.Buttons) + @foreach (MonitorMessageButton button in message.Buttons.GetSnapshot()) { @button.Text } @@ -50,14 +83,31 @@ @code { - // Force refresh every minute to update timestamps + // Refresh once per minute to update relative timestamps. Timer? timer; + IReadOnlyList messages = Array.Empty(); protected override void OnInitialized() { base.OnInitialized(); - timer = new Timer(RefreshCallback, null, 1000, 1000); + // Messages can arrive from EFT log watchers and API callbacks between the + // timestamp refreshes. Listen to the source directly so the card redraws + // as soon as a message is recorded. + messageLog.newMessage += MessageLog_NewMessage; + messages = messageLog.GetSnapshot(); + timer = new Timer(RefreshCallback, null, 60000, 60000); + } + + private void MessageLog_NewMessage(object source, NewLogMessageArgs e) + { + // Log callbacks are not guaranteed to run on Blazor's renderer thread. + // InvokeAsync safely schedules the redraw on the correct dispatcher. + _ = InvokeAsync(() => + { + messages = messageLog.GetSnapshot(); + StateHasChanged(); + }); } private string GetTypeIcon(string type) @@ -101,12 +151,17 @@ private string GetMessageStyle(string url) { if (url.Length == 0) return ""; - return "cursor: pointer;"; + return "cursor: pointer; text-decoration: underline;"; } private void OpenURL(string url) { if (url.Length == 0) return; + if (url.StartsWith('/')) + { + NavManager.NavigateTo(url); + return; + } var psi = new ProcessStartInfo { FileName = url, @@ -157,4 +212,10 @@ messageLog.AddMessage($"Error in message select: ${ex.Message} {ex.StackTrace}", "exception"); } } + + public void Dispose() + { + messageLog.newMessage -= MessageLog_NewMessage; + timer?.Dispose(); + } } diff --git a/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor b/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor index 164c658..51afe8b 100644 --- a/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor +++ b/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor @@ -130,6 +130,7 @@ Id = GameWatcher.CurrentProfile.Id, Type = GameWatcher.CurrentProfile.Type, AccountId = GameWatcher.CurrentProfile.AccountId, + SessionMode = GameWatcher.CurrentProfile.SessionMode, }; customWatcher.ProcessLogsFromBreakpoint(selectedBreakpoint); GameWatcher.CurrentProfile = currentProfile; @@ -199,4 +200,4 @@ { TaskStatuses[e.LogContent.TaskId] = e.LogContent.Status; } -} \ No newline at end of file +} diff --git a/TarkovMonitor/GameWatcher.cs b/TarkovMonitor/GameWatcher.cs index 53149f9..8844d43 100644 --- a/TarkovMonitor/GameWatcher.cs +++ b/TarkovMonitor/GameWatcher.cs @@ -20,6 +20,38 @@ internal class GameWatcher public static Profile CurrentProfile { get; set; } = new(); public static bool ReadingPastLogs = false; public bool InitialLogsRead { get; private set; } = false; + public bool IsGameRunning + { + get + { + try + { + if (process != null && !process.HasExited) + { + return true; + } + + var processes = Process.GetProcessesByName("EscapeFromTarkov"); + try + { + return processes.Length > 0; + } + finally + { + foreach (var candidate in processes) + { + candidate.Dispose(); + } + } + } + catch + { + // If process state cannot be read, block manual historical + // assignment from replacing a potentially live identity. + return true; + } + } + } public string LogsPath { get { @@ -316,6 +348,35 @@ private void LogFileCreateWatcher_Created(object sender, FileSystemEventArgs e) } } + internal static EftSessionMode ResolveSessionMode(string? rawSessionMode) + { + if (string.Equals(rawSessionMode, "Pve", StringComparison.OrdinalIgnoreCase) + || string.Equals(rawSessionMode, "PVE", StringComparison.OrdinalIgnoreCase)) + { + return EftSessionMode.PVE; + } + if (string.Equals(rawSessionMode, "Regular", StringComparison.OrdinalIgnoreCase) + || string.Equals(rawSessionMode, "PVP", StringComparison.OrdinalIgnoreCase)) + { + return EftSessionMode.Regular; + } + if (string.Equals(rawSessionMode, "PvpSeason", StringComparison.OrdinalIgnoreCase) + || string.Equals(rawSessionMode, "Seasonal", StringComparison.OrdinalIgnoreCase) + || string.Equals(rawSessionMode, "SZN", StringComparison.OrdinalIgnoreCase)) + { + return EftSessionMode.Seasonal; + } + + return EftSessionMode.Unknown; + } + + internal static ProfileType ResolveProfileType(EftSessionMode sessionMode) => sessionMode switch + { + EftSessionMode.PVE => ProfileType.PVE, + EftSessionMode.Seasonal => ProfileType.PvpSeason, + _ => ProfileType.Regular, + }; + internal void GameWatcher_NewLogData(object? sender, NewLogDataEventArgs e) { try @@ -356,13 +417,22 @@ internal void GameWatcher_NewLogData(object? sender, NewLogDataEventArgs e) //System.Diagnostics.Debug.WriteLine(eventLine); if (eventLine.Contains("Session mode: ")) { - var modeMatch = Regex.Match(eventLine, @"Session mode: (?\w+)"); + var modeMatch = Regex.Match(eventLine, @"Session mode: (?[^\s|]+)"); if (!modeMatch.Success) { continue; } - CurrentProfile.Type = Enum.Parse(modeMatch.Groups["mode"].Value, true); - raidInfo.Profile = CurrentProfile; + var sessionMode = ResolveSessionMode(modeMatch.Groups["mode"].Value); + if (CurrentProfile.SessionMode != sessionMode) + { + // EFT reports the mode before the matching profile identity. + // Never let the previous mode's identity cross that boundary. + CurrentProfile.Id = ""; + CurrentProfile.AccountId = ""; + } + CurrentProfile.SessionMode = sessionMode; + CurrentProfile.Type = ResolveProfileType(sessionMode); + raidInfo.Profile = CurrentProfile.Snapshot(); continue; } // Profile selection messages have changed names across EFT versions. @@ -720,25 +790,40 @@ public List GetLogDetails(string folderPath) using var fileStream = new FileStream(appLogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var textReader = new StreamReader(fileStream, Encoding.UTF8); var applicationLog = textReader.ReadToEnd(); - var matches = Regex.Matches(applicationLog, @$"{logPatternPrefix}(?\d+\.\d+\.\d+\.\d+)\.\d+\|(?[^|]+)\|(?[^|]+)\|(?:SelectProfile|CompleteSelectedProfile) ProfileId:(?[a-f0-9]+) AccountId:(?\d+)", RegexOptions.Multiline); + var matches = Regex.Matches(applicationLog, @$"{logPatternPrefix}(?\d+\.\d+\.\d+\.\d+)\.\d+\|(?[^|]+)\|(?[^|]+)\|(?:Select(?:ed)?Profile|PrepareSelectedProfileLocally|CompleteSelectedProfile) ProfileId:(?[a-f0-9]+) AccountId:(?\d+)", RegexOptions.Multiline); if (matches.Count == 0) { return logDetails; } - var profileTypeMatches = Regex.Matches(applicationLog, @$"{logPatternPrefix}(?\d+\.\d+\.\d+\.\d+)\.\d+\|(?[^|]+)\|(?[^|]+)\|Session mode: (?\w+)", RegexOptions.Multiline); + var profileTypeMatches = Regex.Matches(applicationLog, @$"{logPatternPrefix}(?\d+\.\d+\.\d+\.\d+)\.\d+\|(?[^|]+)\|(?[^|]+)\|Session mode: (?[^\s|]+)", RegexOptions.Multiline); for (var i = 0; i < matches.Count; i++) { Match match = matches[i]; var dateTimeString = match.Groups["date"].Value + " " + match.Groups["time"].Value; DateTime profileDate = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); - ProfileType profileType = ProfileType.Regular; - if (matches.Count == profileTypeMatches.Count) + var sessionMode = EftSessionMode.Unknown; + Match? sessionModeMatch = null; + foreach (Match candidate in profileTypeMatches) + { + if (candidate.Index > match.Index) + { + break; + } + sessionModeMatch = candidate; + } + if (sessionModeMatch != null) { - profileType = Enum.Parse(profileTypeMatches[i].Groups["profileType"].Value, true); + sessionMode = ResolveSessionMode(sessionModeMatch.Groups["profileType"].Value); } logDetails.Add(new LogDetails() { - Profile = new() { Id = match.Groups["profileId"].Value, Type = profileType }, + Profile = new() + { + Id = match.Groups["profileId"].Value, + Type = ResolveProfileType(sessionMode), + SessionMode = sessionMode, + AccountId = match.Groups["accountId"].Value, + }, AccountId = Int32.Parse(match.Groups["accountId"].Value), Date = profileDate, Version = new Version(match.Groups["version"].Value), @@ -748,6 +833,48 @@ public List GetLogDetails(string folderPath) return logDetails; } + public List DiscoverProfiles() + { + var observations = GetLogFolders() + .Values + .SelectMany(GetLogDetails) + .Select(details => + { + var sessionMode = details.Profile.SessionMode; + return new DiscoveredEftProfile + { + Profile = new Profile + { + AccountId = details.AccountId.ToString(CultureInfo.InvariantCulture), + Id = details.Profile.Id, + SessionMode = sessionMode, + Type = ResolveProfileType(sessionMode), + }, + FirstSeenUtc = new DateTimeOffset(details.Date).ToUniversalTime(), + LastSeenUtc = new DateTimeOffset(details.Date).ToUniversalTime(), + }; + }) + .Where(observation => observation.Profile.HasIdentity + && observation.Profile.SupportsTarkovTrackerWrites) + .ToList(); + + return observations + .GroupBy(observation => new + { + observation.Profile.AccountId, + observation.Profile.Id, + observation.Profile.SessionMode, + }) + .Select(group => new DiscoveredEftProfile + { + Profile = group.First().Profile.Snapshot(), + FirstSeenUtc = group.Min(observation => observation.FirstSeenUtc), + LastSeenUtc = group.Max(observation => observation.LastSeenUtc), + }) + .OrderByDescending(observation => observation.LastSeenUtc) + .ToList(); + } + public List GetLogBreakpoints(string profileId) { List breakpoints = new(); @@ -1047,7 +1174,7 @@ public class RaidInfoEventArgs : EventArgs public RaidInfoEventArgs(RaidInfo raidInfo, Profile profile) { RaidInfo = raidInfo; - Profile = profile; + Profile = profile.Snapshot(); } } public class ExceptionEventArgs : EventArgs @@ -1090,6 +1217,13 @@ public class LogDetails public string Folder { get; set; } } + public sealed class DiscoveredEftProfile + { + public Profile Profile { get; set; } = new(); + public DateTimeOffset FirstSeenUtc { get; set; } + public DateTimeOffset LastSeenUtc { get; set; } + } + public enum ProfileType { PVE, @@ -1112,11 +1246,52 @@ public static class ProfileTypeExtensions }; } + public enum EftSessionMode + { + Unknown, + PVE, + Regular, + Seasonal, + } + public class Profile { public string Id { get; set; } = ""; public ProfileType Type { get; set; } = ProfileType.Regular; + public EftSessionMode SessionMode { get; set; } = EftSessionMode.Unknown; public string AccountId { get; set; } = ""; + public string DisplayName => SessionMode switch + { + EftSessionMode.PVE => "PVE", + EftSessionMode.Regular => "Regular (PVP)", + EftSessionMode.Seasonal => "Seasonal", + _ => "Unknown", + }; + public string TarkovDevPlayerMode => SessionMode switch + { + EftSessionMode.PVE => "pve", + EftSessionMode.Regular => "pvp", + EftSessionMode.Seasonal => "pvp-season", + _ => "", + }; + public bool HasTarkovDevPlayerRoute => HasIdentity + && !string.IsNullOrWhiteSpace(TarkovDevPlayerMode); + public bool SupportsTarkovTrackerWrites => SessionMode is EftSessionMode.PVE + or EftSessionMode.Regular + or EftSessionMode.Seasonal; + public bool HasIdentity => !string.IsNullOrWhiteSpace(AccountId) + && !string.IsNullOrWhiteSpace(Id); + + public Profile Snapshot() + { + return new Profile + { + Id = Id, + Type = Type, + SessionMode = SessionMode, + AccountId = AccountId, + }; + } } public class ProfileEventArgs : EventArgs @@ -1124,14 +1299,19 @@ public class ProfileEventArgs : EventArgs public Profile Profile { get; set; } public ProfileEventArgs(Profile profile) { - Profile = profile; + Profile = profile.Snapshot(); } } public class LogContentEventArgs : EventArgs where T : JsonLogContent { public T LogContent { get; set; } - public Profile Profile { get; set; } + private Profile profile = new(); + public Profile Profile + { + get => profile; + set => profile = value.Snapshot(); + } } diff --git a/TarkovMonitor/MainBlazorUI.cs b/TarkovMonitor/MainBlazorUI.cs index 2e4f4ff..58faa91 100644 --- a/TarkovMonitor/MainBlazorUI.cs +++ b/TarkovMonitor/MainBlazorUI.cs @@ -61,6 +61,15 @@ protected override CreateParams CreateParams private readonly System.Timers.Timer scavCooldownTimer; private LocalizationService localizationService; private bool inRaid; + private int trackerStatusTransitionDepth; + private readonly object trackerSessionNoticeLock = new(); + private long trackerSessionNoticeGeneration; + private TrackerSessionNoticeIdentity? lastAnnouncedTrackerSession; + + private readonly record struct TrackerSessionNoticeIdentity( + string AccountId, + string ProfileId, + EftSessionMode SessionMode); public MainBlazorUI() { @@ -135,6 +144,7 @@ public MainBlazorUI() eft.GroupDisbanded += Eft_GroupDisbanded; eft.MatchingAborted += Eft_GroupStaleEvent; eft.GameStarted += Eft_GroupStaleEvent; + eft.GameStarted += Eft_GameStarted; eft.MapLoading += Eft_MapLoading; eft.MapLoading += Eft_MapLoading_NavigateToMap; eft.MatchFound += Eft_MatchFound; @@ -150,19 +160,9 @@ public MainBlazorUI() TarkovDev.StartAutoUpdates(); //TarkovDev.UpdatePlayerNames(); - // Update Tarkov Tracker - if (Properties.Settings.Default.tarkovTrackerToken != "" && e.Profile.Id != "") - { - try { - TarkovTracker.SetToken(e.Profile.Id, Properties.Settings.Default.tarkovTrackerToken); - } catch (Exception ex) { - messageLog.AddMessage($"Error setting token from previously saved settings {ex.Message}", "exception"); - } - - Properties.Settings.Default.tarkovTrackerToken = ""; - Properties.Settings.Default.Save(); - } - InitializeProgress(); + // The versioned .org store performs guarded legacy recovery. Keep the + // original settings intact until a recovered key is explicitly assigned. + _ = InitializeProgress(e.Profile); }; try @@ -186,6 +186,7 @@ public MainBlazorUI() }; TarkovTracker.ProgressRetrieved += TarkovTracker_ProgressRetrieved; + TarkovTracker.OrgKeyAutoAssigned += TarkovTracker_OrgKeyAutoAssigned; UpdateCheck.NewVersion += UpdateCheck_NewVersion; UpdateCheck.Error += UpdateCheck_Error; @@ -337,12 +338,16 @@ private void Eft_ControlSettings(object? sender, ControlSettingsEventArgs e) private void Eft_ProfileChanged(object? sender, ProfileEventArgs e) { - if (e.Profile.Id == TarkovTracker.CurrentProfileId) + _ = InitializeProgress(e.Profile); + } + + private void Eft_GameStarted(object? sender, EventArgs e) + { + lock (trackerSessionNoticeLock) { - return; + trackerSessionNoticeGeneration++; + lastAnnouncedTrackerSession = null; } - messageLog.AddMessage(string.Format(localizationService.GetString("UsingProfile"), e.Profile.Type)); - TarkovTracker.SetProfile(e.Profile.Id); } private void Eft_ExitedPostRaidMenus(object? sender, RaidInfoEventArgs e) @@ -582,9 +587,33 @@ private void Eft_GroupDisbanded(object? sender, EventArgs e) groupManager.ClearGroup(); } - private void TarkovTracker_ProgressRetrieved(object? sender, EventArgs e) + private void TarkovTracker_ProgressRetrieved(object? sender, TarkovTracker.ProgressRetrievedEventArgs e) { - messageLog.AddMessage(string.Format(localizationService.GetString("RetrievedDataFromTarkovTracker"), TarkovTracker.Progress.data.displayName, TarkovTracker.Progress.data.playerLevel, TarkovTracker.Progress.data.pmcFaction), "update", $"https://{Properties.Settings.Default.tarkovTrackerDomain}"); + messageLog.AddProtectedMessage( + string.Format( + localizationService.GetString("RetrievedDataFromTarkovTracker"), + e.Progress.data.displayName, + e.Progress.data.playerLevel, + e.Progress.data.pmcFaction), + "update", + new[] + { + new MonitorMessageProtectedValue("Account ID", e.AccountId), + new MonitorMessageProtectedValue("Profile ID", e.ProfileId), + }, + $"https://{Properties.Settings.Default.tarkovTrackerDomain}"); + } + + private void TarkovTracker_OrgKeyAutoAssigned(object? sender, TarkovTracker.OrgKeyAutoAssignedEventArgs e) + { + messageLog.AddProtectedMessage( + $"TarkovTracker.org API key assigned - Mode: {TarkovTracker.GetSessionDisplayName(e.SessionMode)}.", + "info", + new[] + { + new MonitorMessageProtectedValue("Account ID", e.AccountId), + new MonitorMessageProtectedValue("Profile ID", e.ProfileId), + }); } private void Eft_GroupStaleEvent(object? sender, EventArgs e) @@ -611,19 +640,55 @@ private async Task UpdateTarkovDevApiData() } } - private async Task InitializeProgress() + private async Task InitializeProgress(Profile? profile = null) { + var profileSnapshot = (profile ?? GameWatcher.CurrentProfile).Snapshot(); + long noticeGeneration; + lock (trackerSessionNoticeLock) + { + noticeGeneration = trackerSessionNoticeGeneration; + } + + if (TarkovTracker.IsLegacyService + || !profileSnapshot.HasIdentity + || !profileSnapshot.SupportsTarkovTrackerWrites) + { + TarkovTracker.DeactivateProfile(); + return; + } try { - await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Id); + await TarkovTracker.SetProfile(profileSnapshot); } catch (Exception ex) { messageLog.AddMessage($"Error retrieving Tarkov Tracker profile: {ex.Message}"); return; } - messageLog.AddMessage(string.Format(localizationService.GetString("UsingProfile"), GameWatcher.CurrentProfile.Type)); - if (TarkovTracker.GetToken(GameWatcher.CurrentProfile.Id) == "") + var identity = new TrackerSessionNoticeIdentity( + profileSnapshot.AccountId, + profileSnapshot.Id, + profileSnapshot.SessionMode); + lock (trackerSessionNoticeLock) + { + if (noticeGeneration != trackerSessionNoticeGeneration + || lastAnnouncedTrackerSession == identity) + { + return; + } + + lastAnnouncedTrackerSession = identity; + } + + messageLog.AddProtectedMessage( + $"EFT session confirmed - Mode: {profileSnapshot.DisplayName}.", + "info", + new[] + { + new MonitorMessageProtectedValue("Account ID", profileSnapshot.AccountId), + new MonitorMessageProtectedValue("Profile ID", profileSnapshot.Id), + }); + if (TarkovTracker.GetTokenForProfile(profileSnapshot) == "") { messageLog.AddMessage(localizationService.GetString("ToAutomaticallyTrackTaskProgress")); return; @@ -643,6 +708,23 @@ private async Task InitializeProgress() }*/ } + internal void BeginTrackerStatusTransition() + { + Interlocked.Increment(ref trackerStatusTransitionDepth); + TarkovTracker.DeactivateProfile(); + } + + internal void CompleteTrackerStatusTransition() + { + if (Interlocked.Decrement(ref trackerStatusTransitionDepth) < 0) + { + Interlocked.Exchange(ref trackerStatusTransitionDepth, 0); + throw new InvalidOperationException( + "TarkovTracker status transition completed without a matching start."); + } + } + + private void Eft_MatchFound(object? sender, RaidInfoEventArgs e) { if (Properties.Settings.Default.matchFoundAlert) @@ -694,7 +776,11 @@ private async void Eft_TaskFinished(object? sender, LogContentEventArgs messages = new(); public event NewLogMessage newMessage = delegate { }; - public MessageLog() + public IReadOnlyList GetSnapshot() { - Messages = new List(); + lock (messagesLock) + { + return messages.ToList(); + } } - public List Messages { get; set; } - + public void AddMessage(MonitorMessage message) { - Messages.Add(message); + message.Message = LimitMessageLength(message.Message); + AddMessageCore(message); + } + + public void AddMessage(string message, string? type = "", string? url = null, string? linkText = null) + { + var monMessage = new MonitorMessage(LimitMessageLength(message), type, url, linkText); + AddMessageCore(monMessage); + } + + public void AddProtectedMessage( + string message, + string? type, + IEnumerable protectedValues, + string? url = null, + string? linkText = null) + { + var monMessage = new MonitorMessage(LimitMessageLength(message), type, url, linkText); + foreach (var protectedValue in protectedValues + .Where(value => !string.IsNullOrWhiteSpace(value.Label) + && !string.IsNullOrWhiteSpace(value.Value))) + { + monMessage.ProtectedValues.Add(protectedValue); + } + AddMessageCore(monMessage); + } - // Throw event to let watchers know something has changed + private void AddMessageCore(MonitorMessage message) + { + lock (messagesLock) + { + messages.Add(message); + if (messages.Count > MaxMessages) + { + messages.RemoveRange(0, messages.Count - MaxMessages); + } + } + + // Notify after releasing the lock so render callbacks can safely take a snapshot. newMessage(this, new NewLogMessageArgs(message)); } - public void AddMessage(string message, string? type = "", string? url = null) + private static string LimitMessageLength(string message) { - var monMessage = new MonitorMessage(message, type, url); - Messages.Add(monMessage); + if (message.Length <= MaxMessageLength) + { + return message; + } - // Throw event to let watchers know something has changed - newMessage(this, new NewLogMessageArgs(monMessage)); + return message[..(MaxMessageLength - ShortenedMessageSuffix.Length)] + ShortenedMessageSuffix; } } } diff --git a/TarkovMonitor/MonitorMessage.cs b/TarkovMonitor/MonitorMessage.cs index cda42c8..20c9a65 100644 --- a/TarkovMonitor/MonitorMessage.cs +++ b/TarkovMonitor/MonitorMessage.cs @@ -1,20 +1,95 @@ using MudBlazor; -using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Diagnostics; using System.Timers; namespace TarkovMonitor { + public sealed class MonitorMessageCollection + { + private readonly object syncRoot = new(); + private readonly List items = new(); + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public int Count + { + get + { + lock (syncRoot) + { + return items.Count; + } + } + } + + public IReadOnlyList GetSnapshot() + { + lock (syncRoot) + { + return items.ToList(); + } + } + + public void Add(T item) + { + lock (syncRoot) + { + var index = items.Count; + items.Add(item); + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); + } + } + + public bool Remove(T item) + { + lock (syncRoot) + { + var index = items.IndexOf(item); + if (index < 0) + { + return false; + } + + var removedItem = items[index]; + items.RemoveAt(index); + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removedItem, index)); + return true; + } + } + + public void Clear() + { + lock (syncRoot) + { + if (items.Count == 0) + { + return; + } + + var removedItems = items.ToList(); + items.Clear(); + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removedItems, 0)); + } + } + } + public class MonitorMessage { public string Message { get; set; } public DateTime Time { get; set; } = DateTime.Now; public string Type { get; set; } = ""; public string Url { get; set; } = ""; + public string LinkText { get; set; } = ""; public Action? OnClick { get; set; } = null; - public ObservableCollection Buttons { get; set; } = new(); - public ObservableCollection Selects { get; set; } = new(); + public MonitorMessageCollection Buttons { get; } = new(); + public MonitorMessageCollection Selects { get; } = new(); + public List ProtectedValues { get; } = new(); public MonitorMessage(string message) { Message = message; @@ -43,10 +118,11 @@ public MonitorMessage(string message) } }; } - public MonitorMessage(string message, string? type = "", string? url = "") : this(message) + public MonitorMessage(string message, string? type = "", string? url = "", string? linkText = "") : this(message) { Type = type ?? ""; Url = url ?? ""; + LinkText = linkText ?? ""; if (Type == "exception") { Buttons.Add(new("Copy", () => { @@ -73,6 +149,18 @@ private void ButtonExpired(object? sender, EventArgs e) } } + public sealed class MonitorMessageProtectedValue + { + public string Label { get; } + public string Value { get; } + + public MonitorMessageProtectedValue(string label, string value) + { + Label = label; + Value = value; + } + } + public class MonitorMessageButton { public string Text { get; set; } @@ -103,7 +191,7 @@ public double? Timeout { else { buttonTimer = new(timeout ?? 0) { - AutoReset = true, + AutoReset = false, Enabled = true, }; buttonTimer.Elapsed += (object? sender, ElapsedEventArgs e) => @@ -176,4 +264,4 @@ public class MonitorMessageSelectChangedEventArgs : EventArgs { public MonitorMessageSelectOption Selected { get; set; } } -} \ No newline at end of file +} diff --git a/TarkovMonitor/Properties/Settings.Designer.cs b/TarkovMonitor/Properties/Settings.Designer.cs index 4b15938..c1af4d6 100644 --- a/TarkovMonitor/Properties/Settings.Designer.cs +++ b/TarkovMonitor/Properties/Settings.Designer.cs @@ -286,6 +286,54 @@ public string tarkovTrackerTokens { this["tarkovTrackerTokens"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("{}")] + public string tarkovTrackerModeTokens { + get { + return ((string)(this["tarkovTrackerModeTokens"])); + } + set { + this["tarkovTrackerModeTokens"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("{}")] + public string tarkovTrackerVerifiedModeTokenHashes { + get { + return ((string)(this["tarkovTrackerVerifiedModeTokenHashes"])); + } + set { + this["tarkovTrackerVerifiedModeTokenHashes"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("{\"version\":4,\"keys\":[],\"accounts\":[],\"profiles\":[]}")] + public string tarkovTrackerOrgTokenStore { + get { + return ((string)(this["tarkovTrackerOrgTokenStore"])); + } + set { + this["tarkovTrackerOrgTokenStore"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string lastTarkovSessionMode { + get { + return ((string)(this["lastTarkovSessionMode"])); + } + set { + this["lastTarkovSessionMode"] = value; + } + } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] diff --git a/TarkovMonitor/Properties/Settings.settings b/TarkovMonitor/Properties/Settings.settings index 6a08071..34ce281 100644 --- a/TarkovMonitor/Properties/Settings.settings +++ b/TarkovMonitor/Properties/Settings.settings @@ -68,6 +68,18 @@ {} + + {} + + + {} + + + {"version":4,"keys":[],"accounts":[],"profiles":[]} + + + + 00:07:10 diff --git a/TarkovMonitor/TarkovTracker.cs b/TarkovMonitor/TarkovTracker.cs index 7b9b333..7c420cd 100644 --- a/TarkovMonitor/TarkovTracker.cs +++ b/TarkovMonitor/TarkovTracker.cs @@ -1,5 +1,7 @@ -using System.Net; +using System.Net; +using System.Security.Cryptography; using System.Net.Http.Headers; +using System.Text; using System.Text.Json; using System.Transactions; using Refit; @@ -14,35 +16,152 @@ internal interface ITarkovTrackerAPI { HttpClient Client { get; } + [Get("/token")] + Task TestToken([Header("Authorization")] string authorization, CancellationToken cancellationToken = default); + [Get("/progress")] - [Headers("Authorization: Bearer")] - Task GetProgress(); + Task GetProgress([Header("Authorization")] string authorization, CancellationToken cancellationToken = default); [Post("/progress/task/{id}")] - [Headers("Authorization: Bearer")] - Task SetTaskStatus(string id, [Body] TaskStatusBody body); + Task SetTaskStatus(string id, [Body] TaskStatusBody body, [Header("Authorization")] string authorization, CancellationToken cancellationToken = default); [Post("/progress/tasks")] - [Headers("Authorization: Bearer")] - Task SetTaskStatuses([Body] List body); + Task SetTaskStatuses([Body] List body, [Header("Authorization")] string authorization, CancellationToken cancellationToken = default); } private static readonly HttpClient tokenInspectionClient = new(); + internal readonly record struct ActiveRequest( + string ProfileId, + string AccountId, + EftSessionMode SessionMode, + string Token, + long Generation, + ITarkovTrackerAPI Api, + CancellationToken CancellationToken); + + internal sealed class ProfileActivationLease + { + internal ActiveRequest Request { get; } + public ProgressResponse Progress { get; } + + internal ProfileActivationLease(ActiveRequest request, ProgressResponse progress) + { + Request = request; + Progress = progress; + } + } + + public sealed class ProgressRetrievedEventArgs : EventArgs + { + public string ProfileId { get; } + public string AccountId { get; } + public EftSessionMode SessionMode { get; } + public ProgressResponse Progress { get; } + + public ProgressRetrievedEventArgs(string profileId, string accountId, EftSessionMode sessionMode, ProgressResponse progress) + { + ProfileId = profileId; + AccountId = accountId; + SessionMode = sessionMode; + Progress = progress; + } + } + + private static readonly object stateLock = new(); + private static long activationGeneration; + private static long serviceGeneration; + private static CancellationTokenSource activeRequestCancellation = new(); + private static string activeDomain = Properties.Settings.Default.tarkovTrackerDomain; private static ITarkovTrackerAPI api = InitAPI(); public static ProgressResponse Progress { get; private set; } = new(); public static bool ValidToken { get; private set; } = false; - public static bool IsLegacyService => string.Equals( - Properties.Settings.Default.tarkovTrackerDomain, - "tarkovtracker.io", - StringComparison.OrdinalIgnoreCase); + // TarkovTracker.io compatibility store. Keys are EFT profile IDs. private static Dictionary tokens = new(); + // Pre-split TarkovTracker.org store. It is read only for one-at-a-time + // recovery into the versioned profile-bound store below. + private static Dictionary modeTokens = new(StringComparer.OrdinalIgnoreCase); + // Fingerprints for the pre-split .org recovery store. + private static Dictionary verifiedModeTokenHashes = new(StringComparer.OrdinalIgnoreCase); + private static TarkovTrackerOrgStore orgTokenStore = TarkovTrackerOrgStore.Empty(); + private static bool legacyTokenStoreLoaded; + private static bool modeTokenStoreLoaded; + private static bool verificationStoreLoaded; + private static bool orgTokenStoreLoaded; + private static readonly List storageWarnings = new(); private static string currentProfile = ""; - public static string CurrentProfileId { get { return currentProfile; } } + private static string currentAccountId = ""; + private static EftSessionMode currentSessionMode = EftSessionMode.Unknown; + private static string activeToken = ""; + private static readonly object importValidationLock = new(); + private static DateTimeOffset nextImportValidationAllowedAt = DateTimeOffset.MinValue; + private static bool importValidationInProgress; + private static readonly TimeSpan importValidationInterval = TimeSpan.FromMinutes(1); + public static string CurrentProfileId + { + get + { + lock (stateLock) + { + return currentProfile; + } + } + } + public static EftSessionMode CurrentSessionMode + { + get + { + lock (stateLock) + { + return currentSessionMode; + } + } + } + + public sealed class OrgKeyAutoAssignedEventArgs : EventArgs + { + public string DisplayName { get; } + public string AccountId { get; } + public string ProfileId { get; } + public EftSessionMode SessionMode { get; } + + public OrgKeyAutoAssignedEventArgs( + string displayName, + string accountId, + string profileId, + EftSessionMode sessionMode) + { + DisplayName = displayName; + AccountId = accountId; + ProfileId = profileId; + SessionMode = sessionMode; + } + } + public static string CurrentAccountId + { + get + { + lock (stateLock) + { + return currentAccountId; + } + } + } + public static bool IsLegacyService + { + get + { + lock (stateLock) + { + return IsLegacyServiceLocked(); + } + } + } public static event EventHandler? TokenValidated; public static event EventHandler? TokenInvalid; - public static event EventHandler? ProgressRetrieved; + public static event EventHandler? ProgressRetrieved; + public static event EventHandler? OrgKeyAutoAssigned; public static Dictionary Domains = new() { { "tarkovtracker.io", "TarkovTracker.io" }, { "tarkovtracker.org", "TarkovTracker.org" }, @@ -50,168 +169,1557 @@ internal interface ITarkovTrackerAPI static TarkovTracker() { tokenInspectionClient.DefaultRequestHeaders.UserAgent.TryParseAdd($"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name} {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); - tokens = JsonSerializer.Deserialize>(Properties.Settings.Default.tarkovTrackerTokens) ?? tokens; + legacyTokenStoreLoaded = TryDeserializeTokenStore( + Properties.Settings.Default.tarkovTrackerTokens, + StringComparer.Ordinal, + nameof(Properties.Settings.Default.tarkovTrackerTokens), + out tokens); + modeTokenStoreLoaded = TryDeserializeTokenStore( + Properties.Settings.Default.tarkovTrackerModeTokens, + StringComparer.OrdinalIgnoreCase, + nameof(Properties.Settings.Default.tarkovTrackerModeTokens), + out modeTokens); + verificationStoreLoaded = TryDeserializeTokenStore( + Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes, + StringComparer.OrdinalIgnoreCase, + nameof(Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes), + out verifiedModeTokenHashes); + orgTokenStoreLoaded = TarkovTrackerOrgStore.TryParse( + Properties.Settings.Default.tarkovTrackerOrgTokenStore, + out orgTokenStore, + out var orgStoreError); + if (!orgTokenStoreLoaded) + { + storageWarnings.Add( + $"TarkovTracker.org key storage could not be read. Its original value was preserved and key changes are disabled until it is repaired. {orgStoreError}"); + } + if (modeTokenStoreLoaded && verificationStoreLoaded) + { + RemoveStaleVerificationRecords(); + } + } + + private static bool TryDeserializeTokenStore( + string rawValue, + IEqualityComparer comparer, + string settingName, + out Dictionary store) + { + store = new Dictionary(comparer); + try + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + throw new JsonException("The stored value is empty."); + } + + var parsed = JsonSerializer.Deserialize>(rawValue) + ?? throw new JsonException("The stored value is null."); + if (parsed.Any(pair => pair.Key is null || pair.Value is null)) + { + throw new JsonException("The stored value contains a null key or value."); + } + + store = new Dictionary(parsed, comparer); + return true; + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or ArgumentException) + { + var blockedAction = settingName == nameof(Properties.Settings.Default.tarkovTrackerTokens) + ? "Legacy TarkovTracker.io key changes and profile-keyed TarkovTracker.org recovery are disabled" + : "Recovery of previously saved TarkovTracker.org keys is unavailable"; + storageWarnings.Add( + $"Tarkov Tracker storage setting {settingName} could not be read. Its original value was preserved and Tarkov Monitor will not overwrite it. {blockedAction} until the setting is repaired."); + return false; + } + } + + public static IReadOnlyList GetStorageWarnings() + { + lock (stateLock) + { + return storageWarnings.ToList(); + } + } + + public static bool CanChangeOrgKeyStorage + { + get + { + lock (stateLock) + { + return orgTokenStoreLoaded; + } + } + } + + private static void RemoveStaleVerificationRecords() + { + var stalePrefixes = verifiedModeTokenHashes + .Where(pair => !modeTokens.TryGetValue(pair.Key, out var storedToken) + || !string.Equals(pair.Value, ComputeTokenFingerprint(storedToken), StringComparison.Ordinal)) + .Select(pair => pair.Key) + .ToList(); + if (stalePrefixes.Count == 0) + { + return; + } + foreach (var prefix in stalePrefixes) + { + verifiedModeTokenHashes.Remove(prefix); + } + } + + private static bool IsTokenVerified(string prefix, string token) + { + return verifiedModeTokenHashes.TryGetValue(prefix, out var verifiedTokenHash) + && string.Equals(verifiedTokenHash, ComputeTokenFingerprint(token), StringComparison.Ordinal); + } + + private static string ComputeTokenFingerprint(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token.Trim()))); + } + + public static string GetApiBaseUrl(string trackerDomain) + { + if (trackerDomain == "tarkovtracker.org") + { + return "https://api.tarkovtracker.org"; + } + return $"https://{trackerDomain}/api/v2"; + } + + public static bool IsSupportedOrgToken(string? token) + { + var value = token?.Trim() ?? string.Empty; + if (value.Length != 22 || value[3] != '_') + { + return false; + } + + var prefix = value[..3]; + if (prefix is not ("PVE" or "PVP" or "SZN")) + { + return false; + } + + for (var index = 4; index < value.Length; index++) + { + var character = value[index]; + if (!((character >= '0' && character <= '9') + || (character >= 'a' && character <= 'f') + || (character >= 'A' && character <= 'F'))) + { + return false; + } + } + + return true; + } + + private static string GetOrgTokenPrefix(string token) + { + var value = token.Trim(); + return value[..3].ToUpperInvariant(); + } + + private static string GetVerifiedPrefix(string submittedToken, TokenResponse response) + { + VerifyOrgTokenResponse(submittedToken, response); + return GetOrgTokenPrefix(submittedToken); + } + + private static void VerifyOrgTokenResponse(string submittedToken, TokenResponse response) + { + var submitted = submittedToken.Trim(); + if (!IsSupportedOrgToken(submitted)) + { + throw new Exception("The TarkovTracker.org API key format is invalid."); + } + + var returnedToken = response.token?.Trim(); + if (!string.IsNullOrWhiteSpace(returnedToken)) + { + if (!IsSupportedOrgToken(returnedToken) + || !string.Equals(GetOrgTokenPrefix(submitted), GetOrgTokenPrefix(returnedToken), StringComparison.OrdinalIgnoreCase) + || !string.Equals(submitted[4..], returnedToken[4..], StringComparison.Ordinal)) + { + throw new Exception("TarkovTracker returned a different API key than the one supplied."); + } + } + + if (string.IsNullOrWhiteSpace(response.gameMode)) + { + return; + } + + var verifiedPrefix = response.gameMode.Trim().ToLowerInvariant() switch + { + "pve" => "PVE", + "pvp" or "regular" => "PVP", + "seasonal" or "pvpseason" or "pvp-season" or "sn1" or "szn" => "SZN", + _ => throw new Exception("TarkovTracker returned an unsupported game mode for this API key."), + }; + var submittedPrefix = GetOrgTokenPrefix(submitted); + if (!string.Equals(submittedPrefix, verifiedPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new Exception($"This API key is {submittedPrefix}, but TarkovTracker verified it as {verifiedPrefix}."); + } + } + + private static long BeginNewActivationLocked() + { + activeRequestCancellation.Cancel(); + activeRequestCancellation.Dispose(); + activeRequestCancellation = new CancellationTokenSource(); + return ++activationGeneration; + } + + public static ITarkovTrackerAPI InitAPI() + { + var nextDomain = Properties.Settings.Default.tarkovTrackerDomain; + var nextApi = RestService.For(GetApiBaseUrl(nextDomain)); + nextApi.Client.DefaultRequestHeaders.UserAgent.TryParseAdd($"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name} {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); + + lock (stateLock) + { + api = nextApi; + activeDomain = nextDomain; + serviceGeneration++; + BeginNewActivationLocked(); + currentProfile = ""; + currentAccountId = ""; + currentSessionMode = EftSessionMode.Unknown; + activeToken = ""; + ValidToken = false; + Progress = new(); + return api; + } + } + + public static void ResetActiveProfile() + { + DeactivateProfile(); + } + + public static void ResetActiveState() + { + DeactivateProfile(); + } + + public static string GetToken(string profileId) + { + lock (stateLock) + { + return tokens.TryGetValue(profileId, out var token) && !IsImportableToken(token) + ? token + : ""; + } + } + + public static string NormalizeSessionMode(string sessionMode) + { + var trimmedMode = sessionMode?.Trim() ?? ""; + if (string.Equals(trimmedMode, "PVP", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedMode, "Regular", StringComparison.OrdinalIgnoreCase)) + { + return "Regular"; + } + if (string.Equals(trimmedMode, "PVE", StringComparison.OrdinalIgnoreCase)) + { + return "PVE"; + } + if (string.Equals(trimmedMode, "Seasonal", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmedMode, "PvpSeason", StringComparison.OrdinalIgnoreCase)) + { + return "Seasonal"; + } + return string.IsNullOrWhiteSpace(trimmedMode) ? "Unknown" : trimmedMode; + } + + public static string NormalizeSessionMode(EftSessionMode sessionMode) + { + return sessionMode.ToString(); + } + + public static string GetPrefixForSessionMode(string sessionMode) + { + var normalizedMode = NormalizeSessionMode(sessionMode); + if (string.Equals(normalizedMode, "Regular", StringComparison.OrdinalIgnoreCase)) + { + return "PVP"; + } + if (string.Equals(normalizedMode, "PVE", StringComparison.OrdinalIgnoreCase)) + { + return "PVE"; + } + if (string.Equals(normalizedMode, "Seasonal", StringComparison.OrdinalIgnoreCase)) + { + return "SZN"; + } + return ""; + } + + public static string GetPrefixForSessionMode(EftSessionMode sessionMode) + { + return GetPrefixForSessionMode(NormalizeSessionMode(sessionMode)); + } + + private const string LegacyOrgKeyIdPrefix = "legacy-org:"; + + private enum LegacyOrgSourceKind + { + Mode, + Profile, + Singleton, + } + + private sealed record LegacyOrgSource(LegacyOrgSourceKind Kind, string Key); + + private sealed record LegacyOrgCandidate( + string Id, + string Prefix, + string Token, + bool Verified, + IReadOnlyList Sources); + + public sealed record OrgKeySummary( + string Id, + string Prefix, + string DisplayName, + string MaskedToken, + bool IsBound, + string AccountId, + string ProfileId, + EftSessionMode SessionMode, + string AccountNickname, + string ProfileNickname, + bool IsVerified, + bool HasPendingConflict, + bool IsLegacyRecovery, + bool IsQuarantined); + + public sealed record OrgProfileSummary( + string AccountId, + string ProfileId, + EftSessionMode SessionMode, + string DisplayName, + string AccountNickname, + DateTimeOffset? FirstSeenUtc, + DateTimeOffset? LastSeenUtc, + bool HasBoundKey, + bool IsCurrent); + + public sealed record OrgReassignmentSummary( + OrgKeySummary Key, + OrgKeySummary? SwappedKey); + + public static IReadOnlyList GetOrgKeys() + { + lock (stateLock) + { + var storedKeys = orgTokenStore.GetKeys(); + var legacyCandidates = orgTokenStoreLoaded + ? GetLegacyOrgCandidatesLocked() + : Array.Empty(); + var conflictedPendingPrefixes = storedKeys + .Where(key => !key.IsBound) + .Select(key => key.Prefix) + .Concat(legacyCandidates.Select(candidate => candidate.Prefix)) + .GroupBy(prefix => prefix, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var summaries = storedKeys + .Select(key => + { + var summary = ToSummary(key); + return !key.IsBound && conflictedPendingPrefixes.Contains(key.Prefix) + ? summary with { HasPendingConflict = true } + : summary; + }) + .ToList(); + + foreach (var legacyCandidate in legacyCandidates) + { + var summary = ToLegacySummary(legacyCandidate); + summaries.Add(conflictedPendingPrefixes.Contains(legacyCandidate.Prefix) + ? summary with { HasPendingConflict = true } + : summary); + } + return summaries; + } + } + + public static IReadOnlyList GetKnownOrgProfiles() + { + lock (stateLock) + { + var boundKeys = orgTokenStore.GetKeys().Where(key => key.IsBound).ToList(); + return orgTokenStore.GetProfiles() + .Select(profile => + { + var sessionMode = GameWatcher.ResolveSessionMode(profile.SessionMode); + return new OrgProfileSummary( + profile.AccountId, + profile.ProfileId, + sessionMode, + profile.ToProfile().DisplayName, + orgTokenStore.GetAccountNickname(profile.AccountId), + profile.FirstSeenUtc, + profile.LastSeenUtc, + boundKeys.Any(key => string.Equals(key.AccountId, profile.AccountId, StringComparison.Ordinal) + && string.Equals(key.ProfileId, profile.ProfileId, StringComparison.Ordinal) + && string.Equals(key.SessionMode, profile.SessionMode, StringComparison.OrdinalIgnoreCase)), + string.Equals(profile.AccountId, currentAccountId, StringComparison.Ordinal) + && string.Equals(profile.ProfileId, currentProfile, StringComparison.Ordinal) + && sessionMode == currentSessionMode); + }) + .OrderByDescending(profile => profile.IsCurrent) + .ThenByDescending(profile => profile.LastSeenUtc) + .ToList(); + } + } + + public static int SaveDiscoveredOrgProfiles(IEnumerable discoveredProfiles) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + var records = discoveredProfiles.Select(discovered => new TarkovTrackerOrgProfile + { + AccountId = discovered.Profile.AccountId, + ProfileId = discovered.Profile.Id, + SessionMode = NormalizeSessionMode(discovered.Profile.SessionMode), + FirstSeenUtc = discovered.FirstSeenUtc, + LastSeenUtc = discovered.LastSeenUtc, + }).ToList(); + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + return PersistOrgStoreChangeLocked(store => store.RememberProfiles(records)); + } + } + + private static bool IsLegacyServiceLocked() + { + return string.Equals(activeDomain, "tarkovtracker.io", StringComparison.OrdinalIgnoreCase); + } + + private static long CaptureOrgServiceGeneration() + { + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(serviceGeneration); + return serviceGeneration; + } + } + + private static void EnsureOrgServiceGenerationLocked(long expectedGeneration) + { + if (IsLegacyServiceLocked() || serviceGeneration != expectedGeneration) + { + throw new InvalidOperationException("The Tarkov Tracker service changed. Switch to TarkovTracker.org and try again."); + } + } + + public static bool HasPendingOrgKey() + { + lock (stateLock) + { + return orgTokenStore.HasPendingKey() + || (orgTokenStoreLoaded && GetNextLegacyOrgCandidateLocked() != null); + } + } + + public static async Task ImportOrgToken(string apiToken) + { + ValidateImportTokenLocally(apiToken); + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + var trimmedToken = apiToken.Trim(); + var suppliedPrefix = GetTokenPrefix(trimmedToken); + + EnsureOrgProfileStoreWritable(); + lock (stateLock) + { + if (orgTokenStore.HasPendingKey(suppliedPrefix) + || GetLegacyOrgCandidateLocked(suppliedPrefix) != null) + { + throw new InvalidOperationException( + $"Assign or remove the unassigned {GetPrefixDisplayName(suppliedPrefix)} API key before importing another one."); + } + } + + lock (stateLock) + { + if (orgTokenStore.ContainsToken(trimmedToken)) + { + throw new DuplicateImportedTokenException( + $"This {GetPrefixDisplayName(suppliedPrefix)} API key is already saved locally."); + } + } + BeginImportValidationCall(); + try + { + var response = await InspectToken(trimmedToken, "tarkovtracker.org"); + if (!response.permissions.Contains("WP")) + { + throw new Exception("This API key is valid but does not have write permission."); + } + var prefix = GetVerifiedPrefix(trimmedToken, response); + TarkovTrackerOrgKey savedKey; + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + if (orgTokenStore.HasPendingKey(prefix) + || GetLegacyOrgCandidateLocked(prefix) != null) + { + throw new InvalidOperationException( + $"Another unassigned {GetPrefixDisplayName(prefix)} API key was added before this import finished."); + } + savedKey = PersistOrgStoreChangeLocked(store => + store.AddVerifiedToken(trimmedToken, prefix, null)); + } + CompleteImportValidationCall(true); + return new ImportedToken( + savedKey.Id, + prefix, + GetPrefixDisplayName(prefix)); + } + catch + { + CompleteImportValidationCall(false); + throw; + } + } + + public static Task BindOrgKey(string id, Profile profile) + { + return BindOrgKeyCore(id, profile, requireCurrentProfile: true); + } + + public static Task AssignOrgKey( + string id, + string accountId, + string profileId, + EftSessionMode sessionMode) + { + return BindOrgKeyCore(id, new Profile + { + AccountId = accountId, + Id = profileId, + SessionMode = sessionMode, + Type = GameWatcher.ResolveProfileType(sessionMode), + }, requireCurrentProfile: false); + } + + private static async Task BindOrgKeyCore( + string id, + Profile profile, + bool requireCurrentProfile) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + + if (id.StartsWith(LegacyOrgKeyIdPrefix, StringComparison.Ordinal)) + { + string prefix; + string token; + bool alreadyVerified; + lock (stateLock) + { + var candidate = GetLegacyOrgCandidateByIdLocked(id); + if (candidate == null) + { + throw new KeyNotFoundException("The pending API key was not found."); + } + prefix = candidate.Prefix; + token = candidate.Token; + alreadyVerified = candidate.Verified; + } + + if (!alreadyVerified) + { + BeginImportValidationCall(); + try + { + var response = await InspectToken(token, "tarkovtracker.org"); + if (!response.permissions.Contains("WP")) + { + throw new Exception("This API key is valid but does not have write permission."); + } + var verifiedPrefix = GetVerifiedPrefix(token, response); + if (!string.Equals(prefix, verifiedPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new Exception("The saved API key mode no longer matches its verified mode."); + } + CompleteImportValidationCall(true); + } + catch + { + CompleteImportValidationCall(false); + throw; + } + } + + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + var bindingProfile = requireCurrentProfile + ? EnsureCurrentBindingProfileLocked(profile) + : orgTokenStore.GetKnownProfile(profile.AccountId, profile.Id, profile.SessionMode); + var candidate = GetLegacyOrgCandidateByIdLocked(id); + if (candidate == null + || !string.Equals(candidate.Prefix, prefix, StringComparison.OrdinalIgnoreCase) + || !string.Equals(candidate.Token, token, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The unassigned API key changed before assignment finished."); + } + + var savedKey = PersistLegacyRecoveryBindingLocked(candidate, bindingProfile); + return ToSummary(savedKey); + } + } + + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + TarkovTrackerOrgKey boundKey; + if (requireCurrentProfile) + { + var bindingProfile = EnsureCurrentBindingProfileLocked(profile); + boundKey = PersistOrgStoreChangeLocked(store => + { + store.RememberAndAutoBind(bindingProfile, DateTimeOffset.UtcNow); + var existing = store.GetKey(id); + return existing?.IsBound == true + ? existing + : store.Bind(id, bindingProfile); + }); + } + else + { + boundKey = PersistOrgStoreChangeLocked(store => store.BindKnownProfile( + id, + profile.AccountId, + profile.Id, + profile.SessionMode)); + } + return ToSummary(boundKey); + } + } + + public static OrgReassignmentSummary ReassignOrgKey( + string id, + string accountId, + string profileId, + EftSessionMode sessionMode) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + var result = PersistOrgStoreChangeLocked(store => store.ReassignKnownProfile( + id, + accountId, + profileId, + sessionMode)); + return new OrgReassignmentSummary( + ToSummary(result.Key), + result.SwappedKey == null ? null : ToSummary(result.SwappedKey)); + } + } + + public static bool RemoveOrgKey(string id) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + if (id.StartsWith(LegacyOrgKeyIdPrefix, StringComparison.Ordinal)) + { + var candidate = GetLegacyOrgCandidateByIdLocked(id); + return candidate != null && RemoveLegacyOrgCandidateLocked(candidate); + } + + return PersistOrgStoreChangeLocked(store => store.Remove(id)); + } + } + + public static OrgKeySummary UnbindOrgKey(string id) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + var key = orgTokenStore.GetKey(id) + ?? throw new KeyNotFoundException("The saved API key was not found."); + if (GetLegacyOrgCandidateLocked(key.Prefix) != null) + { + throw new InvalidOperationException( + $"Assign or remove the unassigned {GetPrefixDisplayName(key.Prefix)} API key before unbinding another one."); + } + var unboundKey = PersistOrgStoreChangeLocked(store => store.Unbind(id)); + return ToSummary(unboundKey); + } + } + + public static string SetOrgAccountNickname(string accountId, string nickname) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + return PersistOrgStoreChangeLocked(store => store.SetAccountNickname(accountId, nickname)); + } + } + + public static string SetOrgProfileNickname(string id, string nickname) + { + var expectedServiceGeneration = CaptureOrgServiceGeneration(); + EnsureOrgProfileStoreWritable(); + + lock (stateLock) + { + EnsureOrgServiceGenerationLocked(expectedServiceGeneration); + return PersistOrgStoreChangeLocked(store => store.SetProfileNickname(id, nickname)); + } + } + + private static OrgKeySummary ToSummary(TarkovTrackerOrgKey key) + { + var sessionMode = key.IsBound + ? GameWatcher.ResolveSessionMode(key.SessionMode) + : EftSessionMode.Unknown; + return new OrgKeySummary( + key.Id, + key.Prefix, + GetPrefixDisplayName(key.Prefix), + MaskToken(key.Token), + key.IsBound, + key.AccountId, + key.ProfileId, + sessionMode, + key.IsBound ? orgTokenStore.GetAccountNickname(key.AccountId) : "", + key.IsBound ? key.ProfileNickname : "", + TarkovTrackerOrgStore.IsVerified(key), + false, + false, + !string.IsNullOrEmpty(orgTokenStore.GetStoreIssue(key))); + } + + private static OrgKeySummary ToLegacySummary(LegacyOrgCandidate candidate) + { + return new OrgKeySummary( + candidate.Id, + candidate.Prefix, + GetPrefixDisplayName(candidate.Prefix), + MaskToken(candidate.Token), + false, + "", + "", + EftSessionMode.Unknown, + "", + "", + candidate.Verified, + false, + true, + false); + } + + private static string MaskToken(string token) + { + var prefix = GetTokenPrefix(token); + return token.Length <= 4 ? $"{prefix}_••••" : $"{prefix}_••••{token[^4..]}"; + } + + private static IReadOnlyList GetLegacyOrgCandidatesLocked() + { + var sources = new List<(string Prefix, string Token, LegacyOrgSource Source)>(); + if (modeTokenStoreLoaded) + { + foreach (var pair in modeTokens) + { + var token = pair.Value?.Trim() ?? ""; + if (!IsImportableToken(token)) + { + continue; + } + sources.Add(( + GetTokenPrefix(token), + token, + new LegacyOrgSource(LegacyOrgSourceKind.Mode, pair.Key))); + } + } + if (legacyTokenStoreLoaded) + { + foreach (var pair in tokens) + { + var token = pair.Value?.Trim() ?? ""; + if (!IsImportableToken(token)) + { + continue; + } + sources.Add(( + GetTokenPrefix(token), + token, + new LegacyOrgSource(LegacyOrgSourceKind.Profile, pair.Key))); + } + } + + var singletonToken = Properties.Settings.Default.tarkovTrackerToken?.Trim() ?? ""; + if (IsImportableToken(singletonToken)) + { + sources.Add(( + GetTokenPrefix(singletonToken), + singletonToken, + new LegacyOrgSource(LegacyOrgSourceKind.Singleton, ""))); + } + + var storedFingerprints = orgTokenStore.GetKeys() + .Select(key => TarkovTrackerOrgStore.ComputeTokenFingerprint(key.Token)) + .ToHashSet(StringComparer.Ordinal); + return sources + .GroupBy( + source => TarkovTrackerOrgStore.ComputeTokenFingerprint(source.Token), + StringComparer.Ordinal) + .Where(group => !storedFingerprints.Contains(group.Key)) + .Select(group => + { + var source = group.First(); + return new LegacyOrgCandidate( + $"{LegacyOrgKeyIdPrefix}{group.Key}", + source.Prefix, + source.Token, + verificationStoreLoaded && IsTokenVerified(source.Prefix, source.Token), + group.Select(item => item.Source).Distinct().ToList()); + }) + .OrderBy(candidate => candidate.Prefix, StringComparer.Ordinal) + .ThenBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToList(); + } + + private static LegacyOrgCandidate? GetNextLegacyOrgCandidateLocked() + { + return GetLegacyOrgCandidatesLocked().FirstOrDefault(); + } + + private static LegacyOrgCandidate? GetLegacyOrgCandidateLocked(string prefix) + { + return GetLegacyOrgCandidatesLocked().FirstOrDefault(candidate => string.Equals( + candidate.Prefix, + prefix, + StringComparison.OrdinalIgnoreCase)); + } + + private static LegacyOrgCandidate? GetLegacyOrgCandidateByIdLocked(string id) + { + return GetLegacyOrgCandidatesLocked().FirstOrDefault(candidate => string.Equals( + candidate.Id, + id, + StringComparison.Ordinal)); + } + + private static T PersistOrgStoreChangeLocked(Func mutation) + { + EnsureOrgProfileStoreWritable(); + var currentSerialized = orgTokenStore.Serialize(); + var candidateStore = orgTokenStore.Clone(); + var result = mutation(candidateStore); + var candidateSerialized = candidateStore.Serialize(); + if (string.Equals(currentSerialized, candidateSerialized, StringComparison.Ordinal)) + { + return result; + } + var previousSetting = Properties.Settings.Default.tarkovTrackerOrgTokenStore; + try + { + Properties.Settings.Default.tarkovTrackerOrgTokenStore = candidateSerialized; + Properties.Settings.Default.Save(); + orgTokenStore = candidateStore; + InvalidateChangedOrgActivationLocked(); + return result; + } + catch + { + Properties.Settings.Default.tarkovTrackerOrgTokenStore = previousSetting; + throw; + } + } + + private static TarkovTrackerOrgKey PersistLegacyRecoveryBindingLocked( + LegacyOrgCandidate candidate, + Profile profile) + { + var candidateStore = orgTokenStore.Clone(); + var savedKey = candidateStore.AddVerifiedBoundToken(candidate.Token, candidate.Prefix, profile); + var previousOrgSetting = Properties.Settings.Default.tarkovTrackerOrgTokenStore; + var previousModeSetting = Properties.Settings.Default.tarkovTrackerModeTokens; + var previousLegacySetting = Properties.Settings.Default.tarkovTrackerTokens; + var previousSingletonSetting = Properties.Settings.Default.tarkovTrackerToken; + var previousVerificationSetting = Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes; + var previousModeTokens = new Dictionary(modeTokens, StringComparer.OrdinalIgnoreCase); + var previousTokens = new Dictionary(tokens, StringComparer.Ordinal); + var previousVerificationHashes = new Dictionary(verifiedModeTokenHashes, StringComparer.OrdinalIgnoreCase); + try + { + RemoveLegacyOrgSourcesInMemoryLocked(candidate); + Properties.Settings.Default.tarkovTrackerOrgTokenStore = candidateStore.Serialize(); + if (modeTokenStoreLoaded) + { + Properties.Settings.Default.tarkovTrackerModeTokens = JsonSerializer.Serialize(modeTokens); + } + if (legacyTokenStoreLoaded) + { + Properties.Settings.Default.tarkovTrackerTokens = JsonSerializer.Serialize(tokens); + } + if (verificationStoreLoaded) + { + Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes = JsonSerializer.Serialize(verifiedModeTokenHashes); + } + Properties.Settings.Default.Save(); + orgTokenStore = candidateStore; + InvalidateChangedOrgActivationLocked(); + return savedKey; + } + catch + { + modeTokens = previousModeTokens; + tokens = previousTokens; + verifiedModeTokenHashes = previousVerificationHashes; + Properties.Settings.Default.tarkovTrackerOrgTokenStore = previousOrgSetting; + Properties.Settings.Default.tarkovTrackerModeTokens = previousModeSetting; + Properties.Settings.Default.tarkovTrackerTokens = previousLegacySetting; + Properties.Settings.Default.tarkovTrackerToken = previousSingletonSetting; + Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes = previousVerificationSetting; + throw; + } + } + + private static bool RemoveLegacyOrgCandidateLocked(LegacyOrgCandidate candidate) + { + var previousModeSetting = Properties.Settings.Default.tarkovTrackerModeTokens; + var previousLegacySetting = Properties.Settings.Default.tarkovTrackerTokens; + var previousSingletonSetting = Properties.Settings.Default.tarkovTrackerToken; + var previousVerificationSetting = Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes; + var previousModeTokens = new Dictionary(modeTokens, StringComparer.OrdinalIgnoreCase); + var previousTokens = new Dictionary(tokens, StringComparer.Ordinal); + var previousVerificationHashes = new Dictionary(verifiedModeTokenHashes, StringComparer.OrdinalIgnoreCase); + try + { + RemoveLegacyOrgSourcesInMemoryLocked(candidate); + if (modeTokenStoreLoaded) + { + Properties.Settings.Default.tarkovTrackerModeTokens = JsonSerializer.Serialize(modeTokens); + } + if (legacyTokenStoreLoaded) + { + Properties.Settings.Default.tarkovTrackerTokens = JsonSerializer.Serialize(tokens); + } + if (verificationStoreLoaded) + { + Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes = JsonSerializer.Serialize(verifiedModeTokenHashes); + } + Properties.Settings.Default.Save(); + return true; + } + catch + { + modeTokens = previousModeTokens; + tokens = previousTokens; + verifiedModeTokenHashes = previousVerificationHashes; + Properties.Settings.Default.tarkovTrackerModeTokens = previousModeSetting; + Properties.Settings.Default.tarkovTrackerTokens = previousLegacySetting; + Properties.Settings.Default.tarkovTrackerToken = previousSingletonSetting; + Properties.Settings.Default.tarkovTrackerVerifiedModeTokenHashes = previousVerificationSetting; + throw; + } + } + + private static void RemoveLegacyOrgSourcesInMemoryLocked(LegacyOrgCandidate candidate) + { + foreach (var source in candidate.Sources) + { + switch (source.Kind) + { + case LegacyOrgSourceKind.Mode: + if (!modeTokens.TryGetValue(source.Key, out var modeToken) + || !string.Equals(modeToken?.Trim(), candidate.Token, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The saved API key changed before its recovery source could be updated."); + } + modeTokens.Remove(source.Key); + if (verificationStoreLoaded) + { + verifiedModeTokenHashes.Remove(candidate.Prefix); + } + break; + case LegacyOrgSourceKind.Profile: + if (!tokens.TryGetValue(source.Key, out var profileToken) + || !string.Equals(profileToken?.Trim(), candidate.Token, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The saved API key changed before its recovery source could be updated."); + } + tokens.Remove(source.Key); + break; + case LegacyOrgSourceKind.Singleton: + if (!string.Equals( + Properties.Settings.Default.tarkovTrackerToken?.Trim(), + candidate.Token, + StringComparison.Ordinal)) + { + throw new InvalidOperationException("The saved API key changed before its recovery source could be updated."); + } + Properties.Settings.Default.tarkovTrackerToken = ""; + break; + } + } + } + + private static void EnsureOrgProfileStoreWritable() + { + if (!orgTokenStoreLoaded) + { + throw new InvalidOperationException("TarkovTracker.org key storage needs repair before keys can be changed. The unreadable saved value was preserved."); + } + } + + private static Profile EnsureCurrentBindingProfileLocked(Profile profile) + { + if (!profile.SupportsTarkovTrackerWrites + || !string.Equals(profile.Id, currentProfile, StringComparison.Ordinal) + || !string.Equals(profile.AccountId, currentAccountId, StringComparison.Ordinal) + || profile.SessionMode != currentSessionMode) + { + throw new InvalidOperationException("The selected EFT profile changed. Select the matching profile and try again."); + } + return profile.Snapshot(); + } + + private static void InvalidateChangedOrgActivationLocked() + { + if (IsLegacyServiceLocked() || string.IsNullOrWhiteSpace(currentProfile)) + { + return; + } + + var selectedToken = orgTokenStore.GetForProfile(new Profile + { + Id = currentProfile, + AccountId = currentAccountId, + SessionMode = currentSessionMode, + Type = GameWatcher.ResolveProfileType(currentSessionMode), + })?.Token ?? ""; + if (string.Equals(activeToken, selectedToken, StringComparison.Ordinal)) + { + return; + } + + activeToken = ""; + BeginNewActivationLocked(); + ValidToken = false; + Progress = new(); + } + + public static string GetTokenPrefix(string apiToken) + { + var trimmedToken = apiToken.Trim(); + var separatorIndex = trimmedToken.IndexOf('_'); + if (separatorIndex <= 0) + { + return ""; + } + return trimmedToken[..separatorIndex].ToUpperInvariant(); + } + + public static string GetPrefixDisplayName(string prefix) + { + return prefix.ToUpperInvariant() switch + { + "PVP" => "Regular (PVP)", + "PVE" => "PVE", + "SZN" => "Seasonal", + _ => "Unknown", + }; + } + + public static string GetSessionDisplayName(string sessionMode) + { + var normalizedMode = NormalizeSessionMode(sessionMode); + if (string.Equals(normalizedMode, "PVE", StringComparison.OrdinalIgnoreCase)) + { + return "PVE"; + } + if (string.Equals(normalizedMode, "Regular", StringComparison.OrdinalIgnoreCase)) + { + return "Regular (PVP)"; + } + if (string.Equals(normalizedMode, "Seasonal", StringComparison.OrdinalIgnoreCase)) + { + return "Seasonal"; + } + return "Unknown"; + } + + public static string GetSessionDisplayName(EftSessionMode sessionMode) + { + return GetSessionDisplayName(NormalizeSessionMode(sessionMode)); + } + + public static bool IsSupportedPrefix(string prefix) + { + return prefix.Equals("PVP", StringComparison.OrdinalIgnoreCase) + || prefix.Equals("PVE", StringComparison.OrdinalIgnoreCase) + || prefix.Equals("SZN", StringComparison.OrdinalIgnoreCase); + } + + internal static bool IsImportablePrefix(string prefix) + { + return prefix is "PVP" or "PVE" or "SZN"; + } + + public static bool IsImportableToken(string apiToken) + { + try + { + ValidateImportTokenLocally(apiToken); + return true; + } + catch + { + return false; + } + } + + private static void ValidateImportTokenLocally(string apiToken) + { + if (string.IsNullOrEmpty(apiToken)) + { + throw new Exception("Paste a TarkovTracker.org API key before validating."); + } + if (!string.Equals(apiToken, apiToken.Trim(), StringComparison.Ordinal)) + { + throw new Exception("The API key contains a space before or after the key. Copy it directly from TarkovTracker.org and try again."); + } + if (apiToken.Any(character => character > 127)) + { + throw new Exception("The API key contains a non-ASCII character. Copy it directly from TarkovTracker.org instead of typing or editing it manually."); + } + + var separatorIndex = apiToken.IndexOf('_'); + var hasOneSeparator = separatorIndex == apiToken.LastIndexOf('_'); + var prefix = separatorIndex > 0 ? apiToken[..separatorIndex] : ""; + var identifier = separatorIndex >= 0 && separatorIndex < apiToken.Length - 1 + ? apiToken[(separatorIndex + 1)..] + : ""; + var identifierIsHexadecimal = identifier.All(Uri.IsHexDigit); + if (separatorIndex != 3 + || !hasOneSeparator + || !IsImportablePrefix(prefix) + || identifier.Length != 18 + || !identifierIsHexadecimal) + { + throw new Exception("The API key format is invalid. Expected PVP_, PVE_, or SZN_ followed by an 18-character hexadecimal identifier. Copy the key directly from TarkovTracker.org and try again."); + } + } + + private static void BeginImportValidationCall() + { + lock (importValidationLock) + { + var now = DateTimeOffset.UtcNow; + if (now < nextImportValidationAllowedAt) + { + var secondsRemaining = Math.Max(1, (int)Math.Ceiling((nextImportValidationAllowedAt - now).TotalSeconds)); + throw new Exception($"Please wait {secondsRemaining} seconds before verifying another API key."); + } + if (importValidationInProgress) + { + throw new Exception("An API key verification is already in progress."); + } + importValidationInProgress = true; + } + } + + private static void CompleteImportValidationCall(bool succeeded) + { + lock (importValidationLock) + { + importValidationInProgress = false; + nextImportValidationAllowedAt = succeeded + ? DateTimeOffset.MinValue + : DateTimeOffset.UtcNow.Add(importValidationInterval); + } + } + + public static int GetImportValidationCooldownSeconds() + { + lock (importValidationLock) + { + return Math.Max(0, (int)Math.Ceiling((nextImportValidationAllowedAt - DateTimeOffset.UtcNow).TotalSeconds)); + } + } + + public static string GetTokenForProfile(Profile profile) + { + lock (stateLock) + { + return GetTokenForProfileLocked(profile); + } + } + + private static string GetTokenForProfileLocked(Profile profile) + { + if (IsLegacyServiceLocked()) + { + return tokens.TryGetValue(profile.Id, out var token) && !IsImportableToken(token) + ? token + : ""; + } + return orgTokenStore.GetForProfile(profile)?.Token ?? ""; + } + + public record ImportedToken( + string Id, + string Prefix, + string DisplayName); + + public sealed class DuplicateImportedTokenException : Exception + { + public DuplicateImportedTokenException(string message) : base(message) + { + } + } + public static void SetToken(string profileId, string token) + { + if (IsLegacyService) + { + return; + } + if (profileId == "") + { + throw new Exception("No EFT profile initialized, please launch Escape from Tarkov first"); + } + if (!legacyTokenStoreLoaded) + { + throw new InvalidOperationException("Legacy Tarkov Tracker token storage could not be read, so it was not overwritten."); + } + if (IsImportableToken(token)) + { + throw new InvalidOperationException("This is a TarkovTracker.org API key. Switch to TarkovTracker.org to recover or manage it."); + } + + lock (stateLock) + { + var originalTokens = new Dictionary(tokens, StringComparer.Ordinal); + var originalSetting = Properties.Settings.Default.tarkovTrackerTokens; + var previousToken = tokens.TryGetValue(profileId, out var storedToken) ? storedToken : ""; + tokens[profileId] = token; + try + { + Properties.Settings.Default.tarkovTrackerTokens = JsonSerializer.Serialize(tokens); + Properties.Settings.Default.Save(); + } + catch + { + tokens = originalTokens; + Properties.Settings.Default.tarkovTrackerTokens = originalSetting; + throw; + } + + // Editing the active legacy profile's token immediately revokes the + // previous activation. The separate .org store is unaffected. + if (IsLegacyServiceLocked() && profileId == currentProfile && previousToken != token) + { + BeginNewActivationLocked(); + ValidToken = false; + Progress = new(); + } + } } - public static string GetApiBaseUrl(string trackerDomain) + public static void DeactivateProfile() { - if (trackerDomain == "tarkovtracker.org") + lock (stateLock) { - return "https://api.tarkovtracker.org"; + currentProfile = ""; + currentAccountId = ""; + currentSessionMode = EftSessionMode.Unknown; + activeToken = ""; + BeginNewActivationLocked(); + ValidToken = false; + Progress = new(); } - return $"https://{trackerDomain}/api/v2"; } - public static bool IsSupportedOrgToken(string? token) + public static ProgressResponse? GetActiveProgressSnapshot(Profile expectedProfile) { - var value = token?.Trim() ?? string.Empty; - if (value.Length != 22 || value[3] != '_') + lock (stateLock) { - return false; + return ValidToken + && currentProfile == expectedProfile.Id + && currentAccountId == expectedProfile.AccountId + && currentSessionMode == expectedProfile.SessionMode + ? Progress + : null; } + } - var prefix = value[..3].ToUpperInvariant(); - if (prefix is not ("PVE" or "PVP" or "SZN")) + public static async Task SetProfile(Profile profile, bool forceRefresh = false) + { + if (IsLegacyService) { - return false; + DeactivateProfile(); + return Progress; + } + if (!profile.HasIdentity || !profile.SupportsTarkovTrackerWrites) + { + DeactivateProfile(); + return Progress; } - for (var index = 4; index < value.Length; index++) + var profileSnapshot = profile.Snapshot(); + string newToken; + long generation = 0; + ITarkovTrackerAPI targetApi = null!; + CancellationToken requestCancellation = default; + ProgressResponse? unchangedProgress = null; + OrgKeyAutoAssignedEventArgs? autoAssignment = null; + lock (stateLock) { - var character = value[index]; - if (!((character >= '0' && character <= '9') - || (character >= 'a' && character <= 'f') - || (character >= 'A' && character <= 'F'))) + if (!IsLegacyServiceLocked() + && orgTokenStoreLoaded + && profileSnapshot.HasIdentity + && !string.IsNullOrWhiteSpace(profileSnapshot.Id) + && profileSnapshot.SessionMode is EftSessionMode.PVE or EftSessionMode.Regular or EftSessionMode.Seasonal) { - return false; + var existingKey = orgTokenStore.GetForProfile(profileSnapshot); + var prefix = GetPrefixForSessionMode(profileSnapshot.SessionMode); + var allowAutoBind = GetLegacyOrgCandidateLocked(prefix) == null; + var selectedKey = PersistOrgStoreChangeLocked(store => + store.RememberAndAutoBind(profileSnapshot, DateTimeOffset.UtcNow, allowAutoBind)); + newToken = selectedKey?.Token ?? ""; + if (existingKey == null && selectedKey?.IsBound == true) + { + autoAssignment = new OrgKeyAutoAssignedEventArgs( + GetPrefixDisplayName(selectedKey.Prefix), + selectedKey.AccountId, + selectedKey.ProfileId, + GameWatcher.ResolveSessionMode(selectedKey.SessionMode)); + } } - } - - return true; - } - - private static string GetOrgTokenPrefix(string token) - { - var value = token.Trim(); - return value[..3].ToUpperInvariant(); - } + else + { + newToken = GetTokenForProfileLocked(profileSnapshot); + } + if (currentProfile == profileSnapshot.Id + && currentAccountId == profileSnapshot.AccountId + && currentSessionMode == profileSnapshot.SessionMode + && activeToken == newToken + && !forceRefresh + && (ValidToken || string.IsNullOrWhiteSpace(newToken))) + { + unchangedProgress = Progress; + } + else + { + currentProfile = profileSnapshot.Id; + currentAccountId = profileSnapshot.AccountId; + currentSessionMode = profileSnapshot.SessionMode; + activeToken = newToken; + generation = BeginNewActivationLocked(); + targetApi = api; + requestCancellation = activeRequestCancellation.Token; - private static void VerifyOrgTokenResponse(string submittedToken, TokenResponse response) - { - var submitted = submittedToken.Trim(); - if (!IsSupportedOrgToken(submitted)) - { - throw new Exception("The TarkovTracker.org API key format is invalid."); + // Clear the previous profile before the first await. Until both token + // inspection and progress retrieval finish, writes must remain disabled. + ValidToken = false; + Progress = new(); + } } - var returnedToken = response.token?.Trim(); - if (!string.IsNullOrWhiteSpace(returnedToken)) + if (autoAssignment != null) { - if (!IsSupportedOrgToken(returnedToken) - || !string.Equals(GetOrgTokenPrefix(submitted), GetOrgTokenPrefix(returnedToken), StringComparison.OrdinalIgnoreCase) - || !string.Equals(submitted[4..], returnedToken[4..], StringComparison.Ordinal)) + foreach (EventHandler handler in + OrgKeyAutoAssigned?.GetInvocationList().Cast>() + ?? Array.Empty>()) { - throw new Exception("TarkovTracker returned a different API key than the one supplied."); + try + { + handler(null, autoAssignment); + } + catch + { + // Assignment is already persisted. A presentation observer must + // never prevent the matching key from continuing activation. + } } } - - if (string.IsNullOrWhiteSpace(response.gameMode)) + if (unchangedProgress != null) { - return; + return unchangedProgress; } - var verifiedPrefix = response.gameMode.Trim().ToLowerInvariant() switch + if (string.IsNullOrWhiteSpace(newToken)) { - "pve" => "PVE", - "pvp" or "regular" => "PVP", - "seasonal" or "pvpseason" or "sn1" => "SZN", - _ => throw new Exception("TarkovTracker returned an unsupported game mode for this API key."), - }; - var submittedPrefix = GetOrgTokenPrefix(submitted); - if (!string.Equals(submittedPrefix, verifiedPrefix, StringComparison.OrdinalIgnoreCase)) + return Progress; + } + + await ActivateProfile(new ActiveRequest( + profileSnapshot.Id, + profileSnapshot.AccountId, + profileSnapshot.SessionMode, + newToken, + generation, + targetApi, + requestCancellation)); + lock (stateLock) { - throw new Exception($"This API key is {submittedPrefix}, but TarkovTracker verified it as {verifiedPrefix}."); + return Progress; } } - public static ITarkovTrackerAPI InitAPI() + public static Task SetProfile(string profileId) { - api = RestService.For(GetApiBaseUrl(Properties.Settings.Default.tarkovTrackerDomain), - new RefitSettings { - AuthorizationHeaderValueGetter = (rq, cr) => { - return new ValueTask(Task.Run(() => { - return GetToken(currentProfile ?? ""); - })); - }, - } - ); - api.Client.DefaultRequestHeaders.UserAgent.TryParseAdd($"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name} {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); - return api; + var profile = GameWatcher.CurrentProfile.Snapshot(); + if (!string.Equals(profile.Id, profileId, StringComparison.Ordinal)) + { + profile.Id = profileId; + profile.AccountId = ""; + profile.SessionMode = EftSessionMode.Unknown; + profile.Type = ProfileType.Regular; + } + return SetProfile(profile); } - public static void ResetActiveState() + public static async Task AcquireProfileLease(Profile profile) { - currentProfile = ""; - ValidToken = false; - Progress = new(); + var profileSnapshot = profile.Snapshot(); + await SetProfile(profileSnapshot, forceRefresh: true); + var request = CaptureActiveRequest( + profileSnapshot.Id, + profileSnapshot.SessionMode, + profileSnapshot.AccountId); + lock (stateLock) + { + if (!IsCurrentLocked(request)) + { + throw new Exception("Tarkov Tracker profile changed before historical processing could begin"); + } + return new ProfileActivationLease(request, Progress); + } } - public static string GetToken(string profileId) + public static void ReleaseProfileLease(ProfileActivationLease lease) { - if (!tokens.ContainsKey(profileId)) + lock (stateLock) { - return ""; + if (!IsCurrentLocked(lease.Request)) + { + return; + } + + currentProfile = ""; + currentAccountId = ""; + currentSessionMode = EftSessionMode.Unknown; + activeToken = ""; + BeginNewActivationLocked(); + ValidToken = false; + Progress = new(); } - return tokens[profileId]; } - public static void SetToken(string profileId, string token) + private static string Bearer(string token) => $"Bearer {token}"; + + private static ActiveRequest CaptureActiveRequest( + string? expectedProfileId = null, + EftSessionMode? expectedSessionMode = null, + string? expectedAccountId = null) { - if (IsLegacyService) - { - return; - } - if (profileId == "") + lock (stateLock) { - throw new Exception("No PVP or PVE profile initialized, please launch Escape from Tarkov first"); + if (expectedProfileId != null && expectedProfileId != currentProfile) + { + throw new Exception("Tarkov Tracker profile changed before the task update; the update was not sent"); + } + if (expectedSessionMode != null && expectedSessionMode != currentSessionMode) + { + throw new Exception("Tarkov Tracker session mode changed before the task update; the update was not sent"); + } + if (expectedAccountId != null && expectedAccountId != currentAccountId) + { + throw new Exception("Tarkov Tracker account changed before the task update; the update was not sent"); + } + if (!ValidToken + || string.IsNullOrWhiteSpace(currentProfile) + || string.IsNullOrWhiteSpace(activeToken)) + { + throw new Exception("Invalid token"); + } + + return new ActiveRequest( + currentProfile, + currentAccountId, + currentSessionMode, + activeToken, + activationGeneration, + api, + activeRequestCancellation.Token); } - tokens[profileId] = token; - Properties.Settings.Default.tarkovTrackerTokens = JsonSerializer.Serialize(tokens); - Properties.Settings.Default.Save(); } - public static async Task SetProfile(string profileId) + private static bool IsCurrentLocked(ActiveRequest request) { - if (IsLegacyService) + var selectedToken = GetTokenForProfileLocked(new Profile { - ResetActiveState(); - return Progress; - } - if (profileId == "") { - throw new Exception("Can't set PVP or PVE profile, please launch Escape from Tarkov and then restart this application"); - } + Id = request.ProfileId, + AccountId = request.AccountId, + SessionMode = request.SessionMode, + Type = GameWatcher.ResolveProfileType(request.SessionMode), + }); + return request.Generation == activationGeneration + && !request.CancellationToken.IsCancellationRequested + && request.ProfileId == currentProfile + && request.AccountId == currentAccountId + && request.SessionMode == currentSessionMode + && request.Token == activeToken + && request.Token == selectedToken + && ReferenceEquals(request.Api, api); + } - if (currentProfile == profileId) - { - return Progress; - } - var newToken = GetToken(profileId); - var oldToken = GetToken(currentProfile); - currentProfile = profileId; - if (oldToken == newToken) + private static bool IsCurrent(ActiveRequest request) + { + lock (stateLock) { - return Progress; + return IsCurrentLocked(request); } - if (newToken == "" || newToken.Length != 22) + } + + private static bool TryInvalidate(ActiveRequest request) + { + lock (stateLock) { - ValidToken = false; + if (!IsCurrentLocked(request)) + { + return false; + } + Progress = new(); - return Progress; + ValidToken = false; + BeginNewActivationLocked(); + return true; } - await TestToken(newToken); - return Progress; } + // stateLock must be held by the caller so an old request cannot update a + // newly activated profile's in-memory progress. private static void SyncStoredStatus(string questId, TaskStatus status) { var storedStatus = Progress.data.tasksProgress.Find(ts => ts.id == questId); @@ -243,22 +1751,32 @@ private static void SyncStoredStatus(string questId, TaskStatus status) } } - public static async Task SetTaskStatus(string questId, TaskStatus status) + private static async Task SendTaskStatus(ActiveRequest request, string questId, TaskStatus status) { - if (IsLegacyService || !ValidToken) + if (!IsCurrent(request)) { - throw new Exception("Invalid token"); + throw new Exception("Tarkov Tracker profile changed before the task update could be sent"); } try { - await api.SetTaskStatus(questId, TaskStatusBody.From(status)); - SyncStoredStatus(questId, status); + await request.Api.SetTaskStatus( + questId, + TaskStatusBody.From(status), + Bearer(request.Token), + request.CancellationToken); + lock (stateLock) + { + if (IsCurrent(request)) + { + SyncStoredStatus(questId, status); + } + } } catch (ApiException ex) { if (ex.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + InvalidTokenException(request); } if (ex.StatusCode == HttpStatusCode.TooManyRequests) { @@ -270,71 +1788,131 @@ public static async Task SetTaskStatus(string questId, TaskStatus status { throw new Exception($"TarkovTracker API error: {ex.Message}"); } + } + + public static async Task SetTaskStatus( + string questId, + TaskStatus status, + string? expectedProfileId = null, + EftSessionMode? expectedSessionMode = null, + string? expectedAccountId = null) + { + await SendTaskStatus( + CaptureActiveRequest(expectedProfileId, expectedSessionMode, expectedAccountId), + questId, + status); return "success"; } - public static async Task SetTaskComplete(string questId) + public static async Task SetTaskComplete( + string questId, + string? expectedProfileId = null, + EftSessionMode? expectedSessionMode = null, + string? expectedAccountId = null) { - await SetTaskStatus(questId, TaskStatus.Finished); + var request = CaptureActiveRequest(expectedProfileId, expectedSessionMode, expectedAccountId); + await SendTaskStatus(request, questId, TaskStatus.Finished); try { - TarkovDev.Tasks.ForEach(task => { - foreach (var failCondition in task.failConditions) + lock (stateLock) + { + if (!IsCurrent(request)) { - if (failCondition.task == null) - { - continue; - } - if (failCondition.task == questId && failCondition.status.Contains("complete")) + return "success"; + } + + TarkovDev.Tasks.ForEach(task => { + foreach (var failCondition in task.failConditions) { - foreach (var taskStatus in Progress.data.tasksProgress) + if (failCondition.task == null) { - if (taskStatus.id == failCondition.task) + continue; + } + if (failCondition.task == questId && failCondition.status?.Contains("complete") == true) + { + foreach (var taskStatus in Progress.data.tasksProgress) { - taskStatus.failed = true; - break; + if (taskStatus.id == failCondition.task) + { + taskStatus.failed = true; + break; + } } + break; } - break; } - } - }); - } + }); + } + } catch (Exception) { - // do something? + // Preserve the successful remote update if optional local inference fails. } return "success"; } - public static async Task SetTaskFailed(string questId) + public static async Task SetTaskFailed( + string questId, + string? expectedProfileId = null, + EftSessionMode? expectedSessionMode = null, + string? expectedAccountId = null) { - return await SetTaskStatus(questId, TaskStatus.Failed); + return await SetTaskStatus( + questId, + TaskStatus.Failed, + expectedProfileId, + expectedSessionMode, + expectedAccountId); } - public static async Task SetTaskStarted(string questId) + public static async Task SetTaskStarted( + string questId, + string? expectedProfileId = null, + EftSessionMode? expectedSessionMode = null, + string? expectedAccountId = null) { - foreach (var taskStatus in Progress.data.tasksProgress) + ActiveRequest request; + bool shouldWrite; + lock (stateLock) { - if (taskStatus.id != questId) - { - continue; - } - if (taskStatus.failed) - { - return await SetTaskStatus(questId, TaskStatus.Started); - } - break; + request = CaptureActiveRequest(expectedProfileId, expectedSessionMode, expectedAccountId); + shouldWrite = Progress.data.tasksProgress.Any(taskStatus => taskStatus.id == questId && taskStatus.failed); + } + + if (!shouldWrite) + { + return "task not marked as failed"; + } + + await SendTaskStatus(request, questId, TaskStatus.Started); + return "success"; + } + + public static async Task SetTaskStatuses( + Dictionary statuses, + string? expectedProfileId = null, + EftSessionMode? expectedSessionMode = null, + string? expectedAccountId = null) + { + var request = CaptureActiveRequest(expectedProfileId, expectedSessionMode, expectedAccountId); + return await SetTaskStatuses(statuses, request); + } + + public static async Task SetTaskStatuses( + Dictionary statuses, + ProfileActivationLease lease) + { + if (!IsCurrent(lease.Request)) + { + throw new Exception("Tarkov Tracker profile changed before historical task updates could be sent"); } - return "task not marked as failed"; + return await SetTaskStatuses(statuses, lease.Request); } - public static async Task SetTaskStatuses(Dictionary statuses) + private static async Task SetTaskStatuses( + Dictionary statuses, + ActiveRequest request) { - if (IsLegacyService || !ValidToken) - { - throw new Exception("Invalid token"); - } List body = new(); foreach (var kvp in statuses) { @@ -342,55 +1920,60 @@ public static async Task SetTaskStatuses(Dictionary status.id = kvp.Key; body.Add(status); } - try - { - await api.SetTaskStatuses(body); - foreach( var kvp in statuses) + if (!IsCurrent(request)) + { + throw new Exception("Tarkov Tracker profile changed before task updates could be sent"); + } + try + { + await request.Api.SetTaskStatuses(body, Bearer(request.Token), request.CancellationToken); + lock (stateLock) { - SyncStoredStatus(kvp.Key, kvp.Value); + if (IsCurrent(request)) + { + foreach (var kvp in statuses) + { + SyncStoredStatus(kvp.Key, kvp.Value); + } + } } - } - catch (ApiException ex) - { - if (ex.StatusCode == HttpStatusCode.Unauthorized) - { - InvalidTokenException(); + } + catch (ApiException ex) + { + if (ex.StatusCode == HttpStatusCode.Unauthorized) + { + InvalidTokenException(request); } if (ex.StatusCode == HttpStatusCode.TooManyRequests) { throw new Exception("Rate limited by Tarkov Tracker API"); } throw new Exception($"Invalid TarkovTracker API response code: {ex.Message}"); - } - catch (Exception ex) - { - throw new Exception($"TarkovTracker API error: {ex.Message}"); - } - return "success"; - } + } + catch (Exception ex) + { + throw new Exception($"TarkovTracker API error: {ex.Message}"); + } + return "success"; + } public static async Task GetProgress() - { - if (IsLegacyService) - { - ResetActiveState(); - return Progress; - } - if (!ValidToken) - { - throw new Exception("Invalid token"); - } + { + var request = CaptureActiveRequest(); try { - Progress = await api.GetProgress(); - ProgressRetrieved?.Invoke(null, new EventArgs()); - return Progress; + var progress = await request.Api.GetProgress(Bearer(request.Token), request.CancellationToken); + if (TryPublishProgress(request, progress)) + { + ProgressRetrieved?.Invoke(null, new(request.ProfileId, request.AccountId, request.SessionMode, progress)); + } + return progress; } catch (ApiException ex) { if (ex.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + InvalidTokenException(request); } if (ex.StatusCode == HttpStatusCode.TooManyRequests) { @@ -404,18 +1987,56 @@ public static async Task GetProgress() } } - public static async Task TestToken(string apiToken) + public static async Task TestToken(string apiToken, bool activate = false) { if (IsLegacyService) { throw new InvalidOperationException( "Support for TarkovTracker.io has been retired. Switch to TarkovTracker.org."); } + var trimmedToken = apiToken.Trim(); + if (!activate) + { + string domain; + lock (stateLock) + { + domain = activeDomain; + } + var response = await InspectToken(trimmedToken, domain); + VerifyOrgTokenResponse(trimmedToken, response); + return response; + } + + ActiveRequest request; + lock (stateLock) + { + if (string.IsNullOrWhiteSpace(currentProfile)) + { + throw new Exception("No EFT profile initialized, please launch Escape from Tarkov first"); + } + + activeToken = trimmedToken; + ValidToken = false; + Progress = new(); + request = new ActiveRequest( + currentProfile, + currentAccountId, + currentSessionMode, + trimmedToken, + BeginNewActivationLocked(), + api, + activeRequestCancellation.Token); + } + + return await ActivateProfile(request); + } + private static async Task InspectToken(string apiToken, string trackerDomain) + { using var request = new HttpRequestMessage( HttpMethod.Get, - $"{GetApiBaseUrl(Properties.Settings.Default.tarkovTrackerDomain).TrimEnd('/')}/token"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiToken.Trim()); + $"{GetApiBaseUrl(trackerDomain).TrimEnd('/')}/token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); HttpResponseMessage httpResponse; try @@ -431,7 +2052,7 @@ public static async Task TestToken(string apiToken) { if (httpResponse.StatusCode == HttpStatusCode.Unauthorized) { - InvalidTokenException(); + throw new Exception("Tarkov Tracker API token is invalid"); } if (httpResponse.StatusCode == HttpStatusCode.TooManyRequests) { @@ -445,28 +2066,93 @@ public static async Task TestToken(string apiToken) var responseBody = await httpResponse.Content.ReadAsStringAsync(); var response = JsonSerializer.Deserialize(responseBody) ?? throw new Exception("TarkovTracker returned an empty token response."); - VerifyOrgTokenResponse(apiToken, response); - if (response.permissions.Contains("WP")) + return response; + } + } + + private static async Task ActivateProfile(ActiveRequest request) + { + try + { + EnsureCurrentRequest(request); + var response = await request.Api.TestToken(Bearer(request.Token), request.CancellationToken); + VerifyOrgTokenResponse(request.Token, response); + var expectedPrefix = GetPrefixForSessionMode(request.SessionMode); + if (string.IsNullOrWhiteSpace(expectedPrefix) + || !string.Equals(expectedPrefix, GetOrgTokenPrefix(request.Token), StringComparison.OrdinalIgnoreCase)) { - ValidToken = true; - await GetProgress(); - TokenValidated?.Invoke(null, new EventArgs()); + throw new Exception("The active API key does not match the current EFT mode."); } - else + if (!response.permissions.Contains("WP")) { - Progress = new(); - ValidToken = false; - TokenInvalid?.Invoke(null, new EventArgs()); + if (TryInvalidate(request)) + { + TokenInvalid?.Invoke(null, new EventArgs()); + } + return response; + } + + // A token is not active until its matching progress has also loaded. + // This prevents old progress from being exposed during a mode switch. + EnsureCurrentRequest(request); + var progress = await request.Api.GetProgress(Bearer(request.Token), request.CancellationToken); + if (TryPublishProgress(request, progress)) + { + TokenValidated?.Invoke(null, new EventArgs()); + ProgressRetrieved?.Invoke(null, new(request.ProfileId, request.AccountId, request.SessionMode, progress)); } return response; } + catch (ApiException ex) + { + if (ex.StatusCode == HttpStatusCode.Unauthorized) + { + InvalidTokenException(request); + } + if (ex.StatusCode == HttpStatusCode.TooManyRequests) + { + throw new Exception("Rate limited by Tarkov Tracker API"); + } + throw new Exception($"Invalid TarkovTracker API response code: {ex.Message}"); + } + catch (Exception ex) + { + throw new Exception($"TarkovTracker API error: {ex.Message}"); + } } - private static void InvalidTokenException() + private static void EnsureCurrentRequest(ActiveRequest request) { - Progress = new(); - ValidToken = false; - TokenInvalid?.Invoke(null, new EventArgs()); + request.CancellationToken.ThrowIfCancellationRequested(); + if (!IsCurrent(request)) + { + throw new OperationCanceledException( + "Tarkov Tracker profile, key, or service changed before the request could be sent.", + request.CancellationToken); + } + } + + private static bool TryPublishProgress(ActiveRequest request, ProgressResponse progress) + { + lock (stateLock) + { + if (!IsCurrentLocked(request)) + { + return false; + } + + Progress = progress; + ValidToken = true; + return true; + } + } + + private static void InvalidTokenException(ActiveRequest request) + { + if (TryInvalidate(request)) + { + TokenInvalid?.Invoke(null, new EventArgs()); + } throw new Exception("Tarkov Tracker API token is invalid"); } diff --git a/TarkovMonitor/TarkovTrackerOrgStore.cs b/TarkovMonitor/TarkovTrackerOrgStore.cs new file mode 100644 index 0000000..98e67e4 --- /dev/null +++ b/TarkovMonitor/TarkovTrackerOrgStore.cs @@ -0,0 +1,913 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TarkovMonitor +{ + internal sealed class TarkovTrackerOrgStoreDocument + { + public const int CurrentVersion = 4; + + [JsonPropertyName("version")] + [JsonRequired] + public int Version { get; set; } = CurrentVersion; + + [JsonPropertyName("keys")] + [JsonRequired] + public List Keys { get; set; } = new(); + + [JsonPropertyName("accounts")] + public List? Accounts { get; set; } + + [JsonPropertyName("profiles")] + public List? Profiles { get; set; } + } + + internal sealed class TarkovTrackerOrgProfile + { + [JsonPropertyName("accountId")] + public string AccountId { get; set; } = ""; + + [JsonPropertyName("profileId")] + public string ProfileId { get; set; } = ""; + + [JsonPropertyName("sessionMode")] + public string SessionMode { get; set; } = ""; + + [JsonPropertyName("firstSeenUtc")] + public DateTimeOffset? FirstSeenUtc { get; set; } + + [JsonPropertyName("lastSeenUtc")] + public DateTimeOffset? LastSeenUtc { get; set; } + + internal TarkovTrackerOrgProfile Clone() + { + return new TarkovTrackerOrgProfile + { + AccountId = AccountId, + ProfileId = ProfileId, + SessionMode = SessionMode, + FirstSeenUtc = FirstSeenUtc, + LastSeenUtc = LastSeenUtc, + }; + } + + internal Profile ToProfile() + { + var resolvedMode = GameWatcher.ResolveSessionMode(SessionMode); + return new Profile + { + AccountId = AccountId, + Id = ProfileId, + SessionMode = resolvedMode, + Type = GameWatcher.ResolveProfileType(resolvedMode), + }; + } + } + + internal sealed class TarkovTrackerOrgAccount + { + [JsonPropertyName("accountId")] + public string AccountId { get; set; } = ""; + + [JsonPropertyName("nickname")] + public string Nickname { get; set; } = ""; + + internal TarkovTrackerOrgAccount Clone() + { + return new TarkovTrackerOrgAccount + { + AccountId = AccountId, + Nickname = Nickname, + }; + } + } + + internal sealed class TarkovTrackerOrgKey + { + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("token")] + public string Token { get; set; } = ""; + + [JsonPropertyName("prefix")] + public string Prefix { get; set; } = ""; + + [JsonPropertyName("accountId")] + public string AccountId { get; set; } = ""; + + [JsonPropertyName("profileId")] + public string ProfileId { get; set; } = ""; + + [JsonPropertyName("sessionMode")] + public string SessionMode { get; set; } = ""; + + [JsonPropertyName("verifiedTokenHash")] + public string VerifiedTokenHash { get; set; } = ""; + + [JsonPropertyName("profileNickname")] + public string ProfileNickname { get; set; } = ""; + + // Version 1 stored nicknames on individual keys. This read-only + // compatibility property is consumed during the version 2 migration. + [JsonPropertyName("nickname")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LegacyNickname { get; set; } + + [JsonIgnore] + public bool IsBound => !string.IsNullOrWhiteSpace(AccountId) + && !string.IsNullOrWhiteSpace(ProfileId) + && !string.IsNullOrWhiteSpace(SessionMode); + + internal TarkovTrackerOrgKey Clone() + { + return new TarkovTrackerOrgKey + { + Id = Id, + Token = Token, + Prefix = Prefix, + AccountId = AccountId, + ProfileId = ProfileId, + SessionMode = SessionMode, + VerifiedTokenHash = VerifiedTokenHash, + ProfileNickname = ProfileNickname, + LegacyNickname = LegacyNickname, + }; + } + } + + internal sealed class TarkovTrackerOrgStore + { + private readonly List keys; + private readonly List accounts; + private readonly List profiles; + + private TarkovTrackerOrgStore( + IEnumerable? keys = null, + IEnumerable? accounts = null, + IEnumerable? profiles = null) + { + this.keys = keys?.Select(key => key.Clone()).ToList() ?? new(); + this.accounts = accounts?.Select(account => account.Clone()).ToList() ?? new(); + this.profiles = profiles?.Select(profile => profile.Clone()).ToList() ?? new(); + } + + public static TarkovTrackerOrgStore Empty() => new(); + + public static bool TryParse(string rawValue, out TarkovTrackerOrgStore store, out string error) + { + store = Empty(); + error = ""; + try + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + throw new JsonException("The stored value is empty."); + } + + var document = JsonSerializer.Deserialize(rawValue) + ?? throw new JsonException("The stored value is null."); + if (document.Version is not (1 or 2 or 3 or TarkovTrackerOrgStoreDocument.CurrentVersion)) + { + throw new JsonException($"Unsupported store version {document.Version}."); + } + if (document.Keys == null) + { + throw new JsonException("The key list is null."); + } + + if (document.Keys.Any(key => key == null)) + { + throw new JsonException("The key list contains a null record."); + } + + var normalizedKeys = document.Keys.Select(Normalize).ToList(); + if (document.Version < 4) + { + normalizedKeys.ForEach(key => key.ProfileNickname = ""); + } + if (normalizedKeys.Any(key => string.IsNullOrWhiteSpace(key.Id))) + { + throw new JsonException("A stored key record has no identifier."); + } + if (normalizedKeys.GroupBy(key => key.Id, StringComparer.Ordinal).Any(group => group.Count() > 1)) + { + throw new JsonException("The stored key list contains duplicate identifiers."); + } + if (normalizedKeys.Any(key => !IsValidProfileNickname(key))) + { + throw new JsonException("A stored profile nickname record is invalid."); + } + + List normalizedAccounts; + if (document.Version == 1) + { + normalizedAccounts = normalizedKeys + .Where(key => key.IsBound && !string.IsNullOrWhiteSpace(key.LegacyNickname)) + .GroupBy(key => key.AccountId, StringComparer.Ordinal) + .Select(group => + { + var nicknames = group + .Select(key => NormalizeNickname(key.LegacyNickname)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (nicknames.Count != 1) + { + throw new JsonException($"Account ID {group.Key} has conflicting saved nicknames."); + } + return new TarkovTrackerOrgAccount + { + AccountId = group.Key, + Nickname = nicknames[0], + }; + }) + .ToList(); + } + else + { + if (document.Accounts == null) + { + throw new JsonException("The account list is null."); + } + if (document.Accounts.Any(account => account == null)) + { + throw new JsonException("The account list contains a null record."); + } + normalizedAccounts = document.Accounts.Select(Normalize).ToList(); + } + + if (normalizedAccounts.GroupBy(account => account.AccountId, StringComparer.Ordinal).Any(group => group.Count() > 1)) + { + throw new JsonException("The stored account list contains duplicate Account IDs."); + } + if (normalizedAccounts.Any(account => !IsValidAccountNickname(account.AccountId, account.Nickname))) + { + throw new JsonException("A stored account nickname record is invalid."); + } + + List normalizedProfiles; + if (document.Version < 3) + { + normalizedProfiles = normalizedKeys + .Where(key => key.IsBound) + .Select(key => new TarkovTrackerOrgProfile + { + AccountId = key.AccountId, + ProfileId = key.ProfileId, + SessionMode = key.SessionMode, + }) + .GroupBy(ProfileIdentity, StringComparer.Ordinal) + .Select(group => group.First()) + .ToList(); + } + else + { + if (document.Profiles == null) + { + throw new JsonException("The profile list is null."); + } + if (document.Profiles.Any(profile => profile == null)) + { + throw new JsonException("The profile list contains a null record."); + } + normalizedProfiles = document.Profiles + .Select(Normalize) + .ToList(); + } + + normalizedProfiles = normalizedProfiles + .Where(IsValidProfile) + .GroupBy(ProfileIdentity, StringComparer.Ordinal) + .Select(MergeProfileObservations) + .ToList(); + + foreach (var key in normalizedKeys) + { + key.LegacyNickname = null; + } + store = new TarkovTrackerOrgStore(normalizedKeys, normalizedAccounts, normalizedProfiles); + return true; + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or ArgumentException) + { + error = ex.Message; + return false; + } + } + + public TarkovTrackerOrgStore Clone() => new(keys, accounts, profiles); + + public string Serialize() + { + return JsonSerializer.Serialize(new TarkovTrackerOrgStoreDocument + { + Keys = keys.Select(key => key.Clone()).ToList(), + Accounts = accounts.Select(account => account.Clone()).ToList(), + Profiles = profiles.Select(profile => profile.Clone()).ToList(), + }); + } + + public IReadOnlyList GetKeys() + { + return keys.Select(key => key.Clone()).ToList(); + } + + public TarkovTrackerOrgKey? GetKey(string id) + { + return keys.FirstOrDefault(key => string.Equals(key.Id, id, StringComparison.Ordinal))?.Clone(); + } + + public IReadOnlyList GetProfiles() + { + return profiles.Select(profile => profile.Clone()).ToList(); + } + + public string GetAccountNickname(string accountId) + { + return accounts.FirstOrDefault(account => string.Equals( + account.AccountId, + accountId, + StringComparison.Ordinal))?.Nickname ?? ""; + } + + public TarkovTrackerOrgKey? GetForProfile(Profile profile) + { + var prefix = TarkovTracker.GetPrefixForSessionMode(profile.SessionMode); + var sessionMode = TarkovTracker.NormalizeSessionMode(profile.SessionMode); + return keys.FirstOrDefault(key => key.IsBound + && string.IsNullOrEmpty(GetStoreIssue(key)) + && string.Equals(key.Prefix, prefix, StringComparison.OrdinalIgnoreCase) + && string.Equals(key.AccountId, profile.AccountId, StringComparison.Ordinal) + && string.Equals(key.ProfileId, profile.Id, StringComparison.Ordinal) + && string.Equals(key.SessionMode, sessionMode, StringComparison.OrdinalIgnoreCase))?.Clone(); + } + + public bool HasPendingKey() + { + return keys.Any(key => !key.IsBound); + } + + public bool HasPendingKey(string prefix) + { + return keys.Any(key => !key.IsBound + && string.Equals(key.Prefix, prefix, StringComparison.OrdinalIgnoreCase)); + } + + public bool ContainsToken(string token) + { + return keys.Any(key => TokenIdentityMatches(key.Token, token)); + } + + public TarkovTrackerOrgKey AddVerifiedToken(string token, string prefix, Profile? bindingProfile) + { + var normalizedToken = token.Trim(); + var normalizedPrefix = prefix.Trim().ToUpperInvariant(); + if (!TarkovTracker.IsImportableToken(normalizedToken) + || !string.Equals( + normalizedPrefix, + TarkovTracker.GetTokenPrefix(normalizedToken), + StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("The verified API key and mode prefix do not match.", nameof(token)); + } + if (keys.Any(key => TokenIdentityMatches(key.Token, normalizedToken))) + { + throw new TarkovTracker.DuplicateImportedTokenException( + $"This {TarkovTracker.GetPrefixDisplayName(normalizedPrefix)} API key is already saved locally."); + } + if (bindingProfile == null && HasPendingKey(normalizedPrefix)) + { + throw new InvalidOperationException( + $"Assign or remove the unassigned {TarkovTracker.GetPrefixDisplayName(normalizedPrefix)} API key before importing another one."); + } + + var key = new TarkovTrackerOrgKey + { + Id = Guid.NewGuid().ToString("N"), + Token = normalizedToken, + Prefix = normalizedPrefix, + VerifiedTokenHash = ComputeTokenFingerprint(normalizedToken), + }; + + if (bindingProfile != null && CanBindToProfile(key, bindingProfile)) + { + ApplyBinding(key, bindingProfile); + EnsureBindingAvailable(key); + } + + keys.Add(key); + return key.Clone(); + } + + public TarkovTrackerOrgKey AddVerifiedBoundToken(string token, string prefix, Profile bindingProfile) + { + var key = AddVerifiedToken(token, prefix, bindingProfile); + if (!key.IsBound) + { + keys.RemoveAll(candidate => string.Equals(candidate.Id, key.Id, StringComparison.Ordinal)); + throw new InvalidOperationException( + $"The {TarkovTracker.GetPrefixDisplayName(prefix)} API key does not match the selected {bindingProfile.DisplayName} profile."); + } + return key; + } + + public TarkovTrackerOrgKey Bind(string id, Profile profile) + { + var key = GetMutableKey(id); + if (key.IsBound) + { + throw new InvalidOperationException("This API key is already assigned."); + } + if (!IsVerified(key)) + { + throw new InvalidOperationException("This API key must be verified before it can be assigned."); + } + if (!string.IsNullOrEmpty(GetStoreIssue(key))) + { + throw new InvalidOperationException("This API key record must be repaired before it can be assigned."); + } + if (!CanBindToProfile(key, profile)) + { + throw new InvalidOperationException( + $"This {TarkovTracker.GetPrefixDisplayName(key.Prefix)} API key cannot be assigned to the selected {profile.DisplayName} profile."); + } + + ApplyBinding(key, profile); + EnsureBindingAvailable(key); + return key.Clone(); + } + + public TarkovTrackerOrgKey BindKnownProfile(string id, string accountId, string profileId, EftSessionMode sessionMode) + { + var profile = GetKnownProfile(accountId, profileId, sessionMode); + return Bind(id, profile); + } + + public TarkovTrackerOrgKey? RememberAndAutoBind( + Profile profile, + DateTimeOffset seenAt, + bool allowAutoBind = true) + { + RememberProfile(profile, seenAt, seenAt); + var existing = GetForProfile(profile); + if (existing != null || !profile.SupportsTarkovTrackerWrites || !allowAutoBind) + { + return existing; + } + + var prefix = TarkovTracker.GetPrefixForSessionMode(profile.SessionMode); + var pending = keys + .Where(key => !key.IsBound + && string.IsNullOrEmpty(GetStoreIssue(key)) + && string.Equals(key.Prefix, prefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return pending.Count == 1 ? Bind(pending[0].Id, profile) : null; + } + + public int RememberProfiles(IEnumerable discoveredProfiles) + { + var changed = 0; + foreach (var discoveredProfile in discoveredProfiles) + { + var normalized = Normalize(discoveredProfile.Clone()); + if (!IsValidProfile(normalized)) + { + continue; + } + if (RememberProfile( + normalized.ToProfile(), + normalized.FirstSeenUtc, + normalized.LastSeenUtc)) + { + changed++; + } + } + return changed; + } + + public (TarkovTrackerOrgKey Key, TarkovTrackerOrgKey? SwappedKey) ReassignKnownProfile( + string id, + string accountId, + string profileId, + EftSessionMode sessionMode) + { + var key = GetMutableKey(id); + if (!key.IsBound) + { + throw new InvalidOperationException("This API key is not assigned."); + } + + var target = GetKnownProfile(accountId, profileId, sessionMode); + if (!CanBindToProfile(key, target)) + { + throw new InvalidOperationException( + $"This {TarkovTracker.GetPrefixDisplayName(key.Prefix)} API key cannot be assigned to the selected {target.DisplayName} profile."); + } + if (BindingMatches(key, target)) + { + throw new InvalidOperationException("This API key is already assigned to that profile."); + } + + var previous = new Profile + { + AccountId = key.AccountId, + Id = key.ProfileId, + SessionMode = GameWatcher.ResolveSessionMode(key.SessionMode), + Type = GameWatcher.ResolveProfileType(GameWatcher.ResolveSessionMode(key.SessionMode)), + }; + var occupant = keys.FirstOrDefault(candidate => candidate.IsBound + && !string.Equals(candidate.Id, key.Id, StringComparison.Ordinal) + && BindingMatches(candidate, target)); + + ApplyBinding(key, target); + if (occupant != null) + { + if (!CanBindToProfile(occupant, previous)) + { + throw new InvalidOperationException("The selected profile already has an incompatible API key."); + } + ApplyBinding(occupant, previous); + } + + EnsureBindingAvailable(key); + if (occupant != null) + { + EnsureBindingAvailable(occupant); + } + return (key.Clone(), occupant?.Clone()); + } + + public TarkovTrackerOrgKey Unbind(string id) + { + var key = GetMutableKey(id); + if (!key.IsBound) + { + throw new InvalidOperationException("This API key is already unassigned."); + } + if (keys.Any(candidate => !candidate.IsBound + && !string.Equals(candidate.Id, key.Id, StringComparison.Ordinal) + && string.Equals(candidate.Prefix, key.Prefix, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"Assign or remove the unassigned {TarkovTracker.GetPrefixDisplayName(key.Prefix)} API key before unbinding another one."); + } + + key.AccountId = ""; + key.ProfileId = ""; + key.SessionMode = ""; + key.ProfileNickname = ""; + return key.Clone(); + } + + public string SetProfileNickname(string id, string nickname) + { + var key = GetMutableKey(id); + if (!key.IsBound || !string.IsNullOrEmpty(GetStoreIssue(key))) + { + throw new InvalidOperationException("Assign this API key before setting its profile nickname."); + } + + var normalizedNickname = NormalizeNickname(nickname); + if (!IsValidNickname(normalizedNickname)) + { + throw new ArgumentException("Enter a nickname between 1 and 32 characters.", nameof(nickname)); + } + + key.ProfileNickname = normalizedNickname; + return normalizedNickname; + } + + public string SetAccountNickname(string accountId, string nickname) + { + if (!keys.Any(key => key.IsBound + && string.IsNullOrEmpty(GetStoreIssue(key)) + && string.Equals(key.AccountId, accountId, StringComparison.Ordinal))) + { + throw new InvalidOperationException("Assign an API key to this account before setting its nickname."); + } + + var normalizedAccountId = accountId.Trim(); + var normalizedNickname = NormalizeNickname(nickname); + if (!IsValidAccountNickname(normalizedAccountId, normalizedNickname)) + { + throw new ArgumentException("Enter a nickname between 1 and 32 characters.", nameof(nickname)); + } + + var account = accounts.FirstOrDefault(candidate => string.Equals( + candidate.AccountId, + normalizedAccountId, + StringComparison.Ordinal)); + if (account == null) + { + accounts.Add(new TarkovTrackerOrgAccount + { + AccountId = normalizedAccountId, + Nickname = normalizedNickname, + }); + } + else + { + account.Nickname = normalizedNickname; + } + return normalizedNickname; + } + + public bool Remove(string id) + { + return keys.RemoveAll(key => string.Equals(key.Id, id, StringComparison.Ordinal)) > 0; + } + + public static bool IsVerified(TarkovTrackerOrgKey key) + { + return !string.IsNullOrWhiteSpace(key.Token) + && IsRecognizedStoredToken(key.Token) + && string.Equals(key.Prefix, TarkovTracker.GetTokenPrefix(key.Token), StringComparison.OrdinalIgnoreCase) + && string.Equals( + key.VerifiedTokenHash, + ComputeTokenFingerprint(key.Token), + StringComparison.Ordinal); + } + + public static string ComputeTokenFingerprint(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token.Trim()))); + } + + private static bool IsRecognizedStoredToken(string token) + { + if (string.IsNullOrEmpty(token) || token != token.Trim()) + { + return false; + } + var pieces = token.Split('_'); + return pieces.Length == 2 + && pieces[0] is "PVE" or "PVP" or "SZN" + && pieces[1].Length == 18 + && pieces[1].All(Uri.IsHexDigit); + } + + private static TarkovTrackerOrgKey Normalize(TarkovTrackerOrgKey key) + { + key.Id = key.Id?.Trim() ?? ""; + // Never silently normalize a saved secret. Import performs its own + // strict validation; persisted whitespace must remain visible to the + // record validator so the key is quarantined instead of activated. + key.Token ??= ""; + key.Prefix = key.Prefix?.Trim().ToUpperInvariant() ?? ""; + key.AccountId = key.AccountId?.Trim() ?? ""; + key.ProfileId = key.ProfileId?.Trim() ?? ""; + key.SessionMode = key.SessionMode?.Trim() ?? ""; + key.VerifiedTokenHash = key.VerifiedTokenHash?.Trim().ToUpperInvariant() ?? ""; + key.ProfileNickname = NormalizeNickname(key.ProfileNickname); + key.LegacyNickname = key.LegacyNickname?.Trim(); + return key; + } + + private static TarkovTrackerOrgAccount Normalize(TarkovTrackerOrgAccount account) + { + account.AccountId = account.AccountId?.Trim() ?? ""; + account.Nickname = NormalizeNickname(account.Nickname); + return account; + } + + private static TarkovTrackerOrgProfile Normalize(TarkovTrackerOrgProfile profile) + { + profile.AccountId = profile.AccountId?.Trim() ?? ""; + profile.ProfileId = profile.ProfileId?.Trim() ?? ""; + profile.SessionMode = TarkovTracker.NormalizeSessionMode( + GameWatcher.ResolveSessionMode(profile.SessionMode)); + return profile; + } + + private static string NormalizeNickname(string? nickname) => nickname?.Trim() ?? ""; + + private static bool IsValidAccountNickname(string accountId, string nickname) + { + return !string.IsNullOrWhiteSpace(accountId) + && accountId.All(char.IsDigit) + && IsValidNickname(nickname); + } + + private static bool IsValidProfileNickname(TarkovTrackerOrgKey key) + { + return string.IsNullOrEmpty(key.ProfileNickname) + || (key.IsBound && IsValidNickname(key.ProfileNickname)); + } + + private static bool IsValidNickname(string nickname) + { + return nickname.Length is >= 1 and <= 32 + && !nickname.Any(char.IsControl); + } + + private static bool IsValidProfile(TarkovTrackerOrgProfile profile) + { + var resolvedMode = GameWatcher.ResolveSessionMode(profile.SessionMode); + return !string.IsNullOrWhiteSpace(profile.AccountId) + && profile.AccountId.All(char.IsDigit) + && !string.IsNullOrWhiteSpace(profile.ProfileId) + && resolvedMode is EftSessionMode.PVE or EftSessionMode.Regular or EftSessionMode.Seasonal + && string.Equals( + profile.SessionMode, + TarkovTracker.NormalizeSessionMode(resolvedMode), + StringComparison.OrdinalIgnoreCase) + && (profile.FirstSeenUtc == null + || profile.LastSeenUtc == null + || profile.FirstSeenUtc <= profile.LastSeenUtc); + } + + private static TarkovTrackerOrgProfile MergeProfileObservations( + IEnumerable observations) + { + var records = observations.ToList(); + var merged = records[0].Clone(); + merged.FirstSeenUtc = records + .Where(record => record.FirstSeenUtc != null) + .Select(record => record.FirstSeenUtc) + .Min(); + merged.LastSeenUtc = records + .Where(record => record.LastSeenUtc != null) + .Select(record => record.LastSeenUtc) + .Max(); + return merged; + } + + private bool RememberProfile( + Profile profile, + DateTimeOffset? firstSeenUtc, + DateTimeOffset? lastSeenUtc) + { + var candidate = Normalize(new TarkovTrackerOrgProfile + { + AccountId = profile.AccountId, + ProfileId = profile.Id, + SessionMode = TarkovTracker.NormalizeSessionMode(profile.SessionMode), + FirstSeenUtc = firstSeenUtc, + LastSeenUtc = lastSeenUtc, + }); + if (!IsValidProfile(candidate)) + { + return false; + } + + var existing = profiles.FirstOrDefault(saved => string.Equals( + ProfileIdentity(saved), + ProfileIdentity(candidate), + StringComparison.Ordinal)); + if (existing == null) + { + profiles.Add(candidate); + return true; + } + + var previousFirstSeen = existing.FirstSeenUtc; + var previousLastSeen = existing.LastSeenUtc; + if (candidate.FirstSeenUtc != null + && (existing.FirstSeenUtc == null || candidate.FirstSeenUtc < existing.FirstSeenUtc)) + { + existing.FirstSeenUtc = candidate.FirstSeenUtc; + } + if (candidate.LastSeenUtc != null + && (existing.LastSeenUtc == null || candidate.LastSeenUtc > existing.LastSeenUtc)) + { + existing.LastSeenUtc = candidate.LastSeenUtc; + } + return previousFirstSeen != existing.FirstSeenUtc || previousLastSeen != existing.LastSeenUtc; + } + + public Profile GetKnownProfile(string accountId, string profileId, EftSessionMode sessionMode) + { + var identity = ProfileIdentity(accountId, profileId, sessionMode); + return profiles.FirstOrDefault(profile => string.Equals( + ProfileIdentity(profile), + identity, + StringComparison.Ordinal))?.ToProfile() + ?? throw new InvalidOperationException("The selected EFT profile is not in the saved profile list. Scan profiles and try again."); + } + + private static string ProfileIdentity(TarkovTrackerOrgProfile profile) + { + return ProfileIdentity( + profile.AccountId, + profile.ProfileId, + GameWatcher.ResolveSessionMode(profile.SessionMode)); + } + + private static string ProfileIdentity(string accountId, string profileId, EftSessionMode sessionMode) + { + return $"{accountId.Trim()}\u001f{profileId.Trim()}\u001f{TarkovTracker.NormalizeSessionMode(sessionMode).ToUpperInvariant()}"; + } + + private static bool BindingMatches(TarkovTrackerOrgKey key, Profile profile) + { + return string.Equals(key.AccountId, profile.AccountId, StringComparison.Ordinal) + && string.Equals(key.ProfileId, profile.Id, StringComparison.Ordinal) + && string.Equals( + key.SessionMode, + TarkovTracker.NormalizeSessionMode(profile.SessionMode), + StringComparison.OrdinalIgnoreCase); + } + + private static bool CanBindToProfile(TarkovTrackerOrgKey key, Profile profile) + { + return profile.HasIdentity + && profile.AccountId.All(char.IsDigit) + && !string.IsNullOrWhiteSpace(profile.Id) + && profile.SupportsTarkovTrackerWrites + && string.Equals( + key.Prefix, + TarkovTracker.GetPrefixForSessionMode(profile.SessionMode), + StringComparison.OrdinalIgnoreCase); + } + + private static void ApplyBinding(TarkovTrackerOrgKey key, Profile profile) + { + key.AccountId = profile.AccountId; + key.ProfileId = profile.Id; + key.SessionMode = TarkovTracker.NormalizeSessionMode(profile.SessionMode); + } + + private void EnsureBindingAvailable(TarkovTrackerOrgKey proposedKey) + { + if (keys.Any(key => key.IsBound + && !string.Equals(key.Id, proposedKey.Id, StringComparison.Ordinal) + && string.Equals(key.AccountId, proposedKey.AccountId, StringComparison.Ordinal) + && string.Equals(key.ProfileId, proposedKey.ProfileId, StringComparison.Ordinal) + && string.Equals(key.SessionMode, proposedKey.SessionMode, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + "The selected EFT account, profile, and mode already have an assigned API key. Reassign, unbind, or remove it before assigning another key."); + } + } + + private TarkovTrackerOrgKey GetMutableKey(string id) + { + return keys.FirstOrDefault(key => string.Equals(key.Id, id, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException("The saved API key was not found."); + } + + private static bool TokenIdentityMatches(string first, string second) + { + return string.Equals( + ComputeTokenFingerprint(first), + ComputeTokenFingerprint(second), + StringComparison.Ordinal); + } + + public static string GetRecordIssue(TarkovTrackerOrgKey key) + { + if (!IsVerified(key)) + { + return "The token verification record is invalid."; + } + + var hasAnyBindingField = !string.IsNullOrWhiteSpace(key.AccountId) + || !string.IsNullOrWhiteSpace(key.ProfileId) + || !string.IsNullOrWhiteSpace(key.SessionMode); + if (!key.IsBound && hasAnyBindingField) + { + return "The binding is incomplete."; + } + if (key.IsBound) + { + var resolvedMode = GameWatcher.ResolveSessionMode(key.SessionMode); + var expectedPrefix = TarkovTracker.GetPrefixForSessionMode(resolvedMode); + if (!key.AccountId.All(char.IsDigit) + || resolvedMode is not (EftSessionMode.Regular or EftSessionMode.PVE or EftSessionMode.Seasonal) + || !string.Equals( + key.Prefix, + expectedPrefix, + StringComparison.OrdinalIgnoreCase)) + { + return "The account, profile, or mode binding is invalid."; + } + } + return ""; + } + + public string GetStoreIssue(TarkovTrackerOrgKey key) + { + var recordIssue = GetRecordIssue(key); + if (!string.IsNullOrEmpty(recordIssue)) + { + return recordIssue; + } + if (keys.Any(candidate => !string.Equals(candidate.Id, key.Id, StringComparison.Ordinal) + && TokenIdentityMatches(candidate.Token, key.Token))) + { + return "The store contains the same token more than once."; + } + if (key.IsBound && keys.Any(candidate => candidate.IsBound + && !string.Equals(candidate.Id, key.Id, StringComparison.Ordinal) + && string.Equals(candidate.AccountId, key.AccountId, StringComparison.Ordinal) + && string.Equals(candidate.ProfileId, key.ProfileId, StringComparison.Ordinal) + && string.Equals(candidate.SessionMode, key.SessionMode, StringComparison.OrdinalIgnoreCase))) + { + return "The store contains more than one key for the same binding."; + } + return ""; + } + } +} From f659649ad1855a3d803a7a75ff0abe037095c53f Mon Sep 17 00:00:00 2001 From: Giribaldi_TTV Date: Mon, 10 Aug 2026 14:08:48 -0700 Subject: [PATCH 02/17] feat(settings): add guided TarkovTracker key management Add verified key import, saved-profile scanning, and assigned/unassigned management drawers for TarkovTracker.org. Support exact profile assignment, reassignment and swaps, unbind/remove workflows, account and profile nicknames, protected identifiers, storage warnings, and retired TarkovTracker.io guidance without exposing raw tokens. --- .../AssignTarkovTrackerKeyDialog.razor | 246 ++++++ .../AssignTarkovTrackerKeyDialog.razor.css | 218 +++++ .../ConfirmTarkovTrackerKeyActionDialog.razor | 60 ++ .../Settings/EditTarkovTrackerKeyDialog.razor | 131 +++ .../EditTarkovTrackerKeyDialog.razor.css | 91 ++ .../ImportTarkovTrackerKeyDialog.razor | 165 ++++ ...etTarkovTrackerAccountNicknameDialog.razor | 67 ++ ...etTarkovTrackerProfileNicknameDialog.razor | 67 ++ .../Blazor/Pages/Settings/Settings.razor | 813 ++++++++++++++++-- .../Blazor/Pages/Settings/Settings.razor.css | 412 +++++++++ .../Settings/TarkovTrackerDetailsDialog.razor | 37 + TarkovMonitor/wwwroot/css/app.css | 273 ++++++ 12 files changed, 2515 insertions(+), 65 deletions(-) create mode 100644 TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor create mode 100644 TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor.css create mode 100644 TarkovMonitor/Blazor/Pages/Settings/ConfirmTarkovTrackerKeyActionDialog.razor create mode 100644 TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor create mode 100644 TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor.css create mode 100644 TarkovMonitor/Blazor/Pages/Settings/ImportTarkovTrackerKeyDialog.razor create mode 100644 TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerAccountNicknameDialog.razor create mode 100644 TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerProfileNicknameDialog.razor create mode 100644 TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css create mode 100644 TarkovMonitor/Blazor/Pages/Settings/TarkovTrackerDetailsDialog.razor diff --git a/TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor new file mode 100644 index 0000000..fb9be77 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor @@ -0,0 +1,246 @@ +@inject GameWatcher eft + + + +
+ + + @(IsReassign ? "Choose another EFT profile." : "Choose an EFT profile.") + @(RequiresVerification + ? "This recovered key must be verified before you can assign it." + : "Only saved profiles for this key's game mode are shown.") + +
+ +
+ @if (IsReassign) + { + Move keyNo key assigned + Swap keysKey already assigned + } + else + { + AvailableNo key assigned + UnavailableKey already assigned + } +
+ + @if (selectedProfile != null) + { +
+ Review assignment +
+
Account
+
@AccountLabel(selectedProfile)
+
Account ID
+
@selectedProfile.AccountId
+
Mode
+
@selectedProfile.DisplayName
+
Last played
+
@LastPlayedValue(selectedProfile)
+
Profile
+
@ShortProfileId(selectedProfile.ProfileId)
+
+ + @(WillSwap + ? "This profile already has a key. Confirming will swap the two keys; neither key will become unassigned. Profile nicknames move with their keys." + : IsReassign + ? $"This key will move to the selected profile.{(string.IsNullOrWhiteSpace(ProfileNickname) ? "" : " Its profile nickname will move with it.")}" + : "This key will be assigned to the selected profile.") + +
+ } + else if (scanning) + { +
+ + Scanning past EFT logs... +
+ } + else if (MatchingProfiles.Count == 0) + { + + @NoMatchingProfilesLead Select + + to check past EFT logs, or launch EFT with the profile you want to use. + + } + else + { +
+ @foreach (var profile in MatchingProfiles) + { + var disabled = !IsReassign && profile.HasBoundKey; + var stateLabel = disabled + ? "Already assigned" + : profile.HasBoundKey + ? "Swap" + : IsReassign + ? "Move" + : "Select"; + + } +
+ } + + @if (selectedProfile == null && !string.IsNullOrWhiteSpace(scanMessage)) + { + @scanMessage + } +
+ + @if (selectedProfile == null) + { + Cancel + + + Scan profiles + + + } + else + { + Back + @ActionLabel + } + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] public string KeyPrefix { get; set; } = ""; + [Parameter] public string KeyDisplayName { get; set; } = ""; + [Parameter] public bool KeyIsBound { get; set; } + [Parameter] public bool RequiresVerification { get; set; } + [Parameter] public string BoundAccountId { get; set; } = ""; + [Parameter] public string BoundProfileId { get; set; } = ""; + [Parameter] public EftSessionMode BoundSessionMode { get; set; } + [Parameter] public string ProfileNickname { get; set; } = ""; + [Parameter] public bool IsReassign { get; set; } + + private bool scanning; + private string scanMessage = ""; + private TarkovTracker.OrgProfileSummary? selectedProfile; + private IReadOnlyList profiles = Array.Empty(); + + private List MatchingProfiles => profiles + .Where(profile => string.Equals( + KeyPrefix, + TarkovTracker.GetPrefixForSessionMode(profile.SessionMode), + StringComparison.OrdinalIgnoreCase)) + .Where(profile => !IsReassign + || !KeyIsBound + || !string.Equals(profile.AccountId, BoundAccountId, StringComparison.Ordinal) + || !string.Equals(profile.ProfileId, BoundProfileId, StringComparison.Ordinal) + || profile.SessionMode != BoundSessionMode) + .OrderByDescending(profile => profile.IsCurrent) + .ThenByDescending(profile => profile.LastSeenUtc) + .ToList(); + + protected override void OnInitialized() + { + ReloadProfiles(); + } + + private void ReloadProfiles() + { + profiles = TarkovTracker.GetKnownOrgProfiles(); + } + + private void Cancel() => MudDialog.Cancel(); + + private void Select(TarkovTracker.OrgProfileSummary profile) + { + selectedProfile = profile; + } + + private void Back() => selectedProfile = null; + + private void Confirm() + { + if (selectedProfile != null) + { + MudDialog.Close(DialogResult.Ok(selectedProfile)); + } + } + + private bool WillSwap => IsReassign && selectedProfile?.HasBoundKey == true; + + private string NoMatchingProfilesLead => + $"{(IsReassign ? "No other" : "No")} saved {KeyDisplayName} profiles are available."; + + private string ActionLabel => WillSwap ? "Swap" : IsReassign ? "Reassign" : "Assign"; + + private async Task ScanProfiles() + { + scanning = true; + scanMessage = ""; + try + { + var discovered = await Task.Run(eft.DiscoverProfiles); + var changed = TarkovTracker.SaveDiscoveredOrgProfiles(discovered); + ReloadProfiles(); + scanMessage = discovered.Count == 0 + ? "No EFT profiles were found in past logs." + : $"Found {discovered.Count} profile{(discovered.Count == 1 ? "" : "s")} and updated {changed} saved entr{(changed == 1 ? "y" : "ies")}."; + } + catch (Exception ex) + { + scanMessage = $"Could not scan profiles: {ex.Message}"; + } + finally + { + scanning = false; + } + } + + private static string AccountLabel(TarkovTracker.OrgProfileSummary profile) + { + return string.IsNullOrWhiteSpace(profile.AccountNickname) + ? $"Account {profile.AccountId}" + : profile.AccountNickname; + } + + private static string LastPlayed(TarkovTracker.OrgProfileSummary profile) + { + return $"Last played: {LastPlayedValue(profile)}"; + } + + private static string LastPlayedValue(TarkovTracker.OrgProfileSummary profile) + { + return profile.LastSeenUtc is { } lastSeen + ? lastSeen.ToLocalTime().ToString("MMM d, yyyy h:mm tt") + : "Unknown"; + } + + private static string ShortProfileId(string profileId) + { + var suffix = profileId.Length <= 6 ? profileId : profileId[^6..]; + return $"Profile …{suffix}"; + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor.css b/TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor.css new file mode 100644 index 0000000..ea256c3 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/AssignTarkovTrackerKeyDialog.razor.css @@ -0,0 +1,218 @@ +.tracker-profile-scan-state { + display: flex; + align-items: center; + gap: 0.6rem; + min-height: 5rem; + justify-content: center; +} + +.tracker-inline-scan-action { + appearance: none; + margin: 0; + padding: 0; + border: 0; + color: var(--mud-palette-info); + background: transparent; + font: inherit; + text-decoration: underline; + text-underline-offset: 0.12em; + cursor: pointer; +} + +.tracker-inline-scan-action:hover { + text-decoration-thickness: 2px; +} + +.tracker-inline-scan-action:focus-visible { + border-radius: 2px; + outline: 1px solid currentColor; + outline-offset: 2px; +} + +.tracker-profile-picker { + display: flex; + max-height: clamp(12rem, 46vh, 21rem); + flex-direction: column; + gap: 0.45rem; + overflow-y: auto; + padding: 0.2rem 0.35rem 0.2rem 0.2rem; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 11px; + background: rgba(0, 0, 0, 0.12); + scrollbar-color: rgba(120, 174, 251, 0.72) rgba(255, 255, 255, 0.04); + scrollbar-width: thin; +} + +.tracker-profile-guidance { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.42rem; + margin-bottom: 0.65rem; +} + +.tracker-profile-guidance-item { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.02rem; + padding: 0.38rem 0.52rem; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + color: rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.02); + font-size: 0.67rem; + line-height: 1.25; +} + +.tracker-profile-guidance-item b { + color: rgba(255, 255, 255, 0.83); + font-size: 0.7rem; +} + +.tracker-profile-confirm { + padding: 0.25rem 0; +} + +.tracker-profile-confirm-title { + margin-bottom: 0.65rem; + color: rgba(255, 255, 255, 0.88); +} + +.tracker-profile-confirm-grid { + display: grid; + grid-template-columns: 6.5rem minmax(0, 1fr); + gap: 0.34rem 0.75rem; + margin: 0; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); +} + +.tracker-profile-confirm-grid dt { + color: rgba(255, 255, 255, 0.57); + font-size: 0.75rem; + font-weight: 600; +} + +.tracker-profile-confirm-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: rgba(255, 255, 255, 0.9); + font-size: 0.8rem; + font-weight: 650; +} + +.tracker-profile-choice { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 10px; + color: inherit; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015)); + text-align: left; + cursor: pointer; + transition: border-color 150ms ease, background-color 150ms ease; +} + +.tracker-profile-choice:not(:disabled):hover, +.tracker-profile-choice:not(:disabled):focus-visible { + border-color: rgba(104, 164, 255, 0.6); + background-color: rgba(104, 164, 255, 0.08); + outline: none; +} + +.tracker-profile-choice--disabled { + cursor: not-allowed; + opacity: 0.64; +} + +.tracker-profile-choice-main { + display: flex; + min-width: 0; + flex-direction: column; +} + +.tracker-profile-choice-title { + overflow: hidden; + font-size: 0.9rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tracker-profile-choice-id, +.tracker-profile-choice-meta { + color: rgba(255, 255, 255, 0.62); + font-size: 0.72rem; +} + +.tracker-profile-choice-meta { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.65rem; + margin-top: 0.16rem; +} + +.tracker-profile-mode { + color: #acd0ff; + font-weight: 700; +} + +.tracker-profile-choice-state { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; +} + +.tracker-profile-state { + padding: 0.08rem 0.38rem; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + font-size: 0.65rem; + font-weight: 700; +} + +.tracker-profile-state--current { + border-color: rgba(87, 194, 142, 0.46); + color: #8ce0b8; + background: rgba(48, 146, 99, 0.14); +} + +.tracker-profile-state--bound { + border-color: rgba(212, 148, 255, 0.4); + color: #deb0ff; + background: rgba(139, 74, 180, 0.14); +} + +.tracker-profile-state--available { + border-color: rgba(111, 166, 255, 0.42); + color: #a9cbff; + background: rgba(61, 112, 194, 0.13); +} + +@media (max-width: 500px) { + .tracker-profile-guidance { + grid-template-columns: 1fr; + } + + .tracker-profile-choice { + align-items: flex-start; + flex-direction: column; + } + + .tracker-profile-choice-state { + flex-direction: row; + } + + .tracker-profile-confirm-grid { + grid-template-columns: 5.5rem minmax(0, 1fr); + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/ConfirmTarkovTrackerKeyActionDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/ConfirmTarkovTrackerKeyActionDialog.razor new file mode 100644 index 0000000..96537ad --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/ConfirmTarkovTrackerKeyActionDialog.razor @@ -0,0 +1,60 @@ + + +
+ + + @Heading + @Message + +
+ +
+ + API key +
+
+
+
Mode
+
@Mode
+
+
+
API key
+
@MaskedToken
+
+
+
Account ID
+
@AccountId
+
+
+
+ + Cancel + @ConfirmLabel + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] public string Heading { get; set; } = "Confirm this change"; + [Parameter] public string Message { get; set; } = "Check the API key details before continuing."; + [Parameter] public string Mode { get; set; } = ""; + [Parameter] public string MaskedToken { get; set; } = ""; + [Parameter] public string AccountId { get; set; } = ""; + [Parameter] public string ConfirmLabel { get; set; } = "Confirm"; + [Parameter] public string Icon { get; set; } = Icons.Material.Filled.Warning; + [Parameter] public bool IsDestructive { get; set; } + + private string ModeBadgeClass => Mode.Contains("PVE", StringComparison.OrdinalIgnoreCase) + ? "tracker-dialog-mode-badge--pve" + : Mode.Contains("Seasonal", StringComparison.OrdinalIgnoreCase) + ? "tracker-dialog-mode-badge--seasonal" + : ""; + + private void Cancel() => MudDialog.Cancel(); + private void Confirm() => MudDialog.Close(DialogResult.Ok(true)); +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor new file mode 100644 index 0000000..99b61bf --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor @@ -0,0 +1,131 @@ + + +
+ + + Linked to this EFT profile. + Tarkov Monitor uses this key when the account, profile, and game mode below are active. + +
+ +
+ + Binding details +
+
+
+ Mode + @Mode +
+
+ Account ID + @AccountId +
+
+ API key + @MaskedToken +
+
+ Last played + @LastPlayed +
+
+ Profile ID + @ProfileId +
+
+ Profile nickname + + + @(HasProfileNickname ? ProfileNickname : "Character name") + + + @(HasProfileNickname ? "Rename" : "Set name") + + +
+
+ @if (IsQuarantined) + { + + This key has conflicting or invalid profile information. Remove it and import it again before assigning it. + + } + @if (KeyChangesDisabled) + { + @KeyChangesDisabledReason + } + +
+ + Manage key +
+
+ + + +
+
+ + Close + +
+ +@code { + public enum KeyAction + { + Reassign, + SetProfileNickname, + Unbind, + Remove, + } + + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] public string Mode { get; set; } = ""; + [Parameter] public string MaskedToken { get; set; } = ""; + [Parameter] public string AccountId { get; set; } = ""; + [Parameter] public string ProfileId { get; set; } = ""; + [Parameter] public string ProfileNickname { get; set; } = ""; + [Parameter] public string LastPlayed { get; set; } = "Unknown"; + [Parameter] public bool IsQuarantined { get; set; } + [Parameter] public bool KeyChangesDisabled { get; set; } + [Parameter] public string KeyChangesDisabledReason { get; set; } = "Saved key storage must be repaired before this action is available."; + + private string ModeBadgeClass => Mode.Contains("PVE", StringComparison.OrdinalIgnoreCase) + ? "tracker-dialog-mode-badge--pve" + : Mode.Contains("Seasonal", StringComparison.OrdinalIgnoreCase) + ? "tracker-dialog-mode-badge--seasonal" + : ""; + + private bool HasProfileNickname => !string.IsNullOrWhiteSpace(ProfileNickname); + private string ReassignTitle => KeyChangesDisabled + ? KeyChangesDisabledReason + : IsQuarantined + ? "Repair or remove this invalid binding before reassigning it" + : "Move this key to another saved profile, or swap it with a key already assigned there"; + + private void Close() => MudDialog.Close(); + private void Reassign() => MudDialog.Close(DialogResult.Ok(KeyAction.Reassign)); + private void SetProfileNickname() => MudDialog.Close(DialogResult.Ok(KeyAction.SetProfileNickname)); + private void Unbind() => MudDialog.Close(DialogResult.Ok(KeyAction.Unbind)); + private void Remove() => MudDialog.Close(DialogResult.Ok(KeyAction.Remove)); +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor.css b/TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor.css new file mode 100644 index 0000000..8ca936f --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/EditTarkovTrackerKeyDialog.razor.css @@ -0,0 +1,91 @@ +.tracker-key-binding-grid { + display: grid; + overflow: hidden; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 11px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.038), rgba(0, 0, 0, 0.11)); +} + +.tracker-key-binding-item { + display: flex; + min-width: 0; + min-height: 1.68rem; + flex-direction: column; + justify-content: center; + gap: 0; + padding: 0.08rem 0.5rem; + border-right: 1px solid rgba(255, 255, 255, 0.065); + border-bottom: 1px solid rgba(255, 255, 255, 0.065); +} + +.tracker-key-binding-item:nth-child(2n) { + border-right: 0; +} + +.tracker-key-binding-item--wide { + grid-column: 1 / -1; + border-right: 0; + border-bottom: 0; +} + +.tracker-key-binding-label { + color: rgba(255, 255, 255, 0.55); + font-size: 0.64rem; + font-weight: 650; +} + +.tracker-key-binding-value { + min-width: 0; + overflow-wrap: anywhere; + color: rgba(255, 255, 255, 0.92); + font-size: 0.73rem; + font-weight: 650; +} + +.tracker-key-binding-value--muted { + color: rgba(255, 255, 255, 0.46); + font-weight: 500; +} + +.tracker-key-nickname-item { + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.tracker-key-nickname-value { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: 0.35rem; +} + +.tracker-key-edit-token { + font-family: Consolas, monospace; + letter-spacing: 0.03em; +} + +@media (max-width: 440px) { + .tracker-key-binding-grid { + grid-template-columns: 1fr; + } + + .tracker-key-binding-item, + .tracker-key-binding-item:nth-child(2n) { + grid-column: 1; + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.065); + } + + .tracker-key-binding-item--wide { + border-bottom: 0; + } + + .tracker-key-nickname-item { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/ImportTarkovTrackerKeyDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/ImportTarkovTrackerKeyDialog.razor new file mode 100644 index 0000000..9945721 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/ImportTarkovTrackerKeyDialog.razor @@ -0,0 +1,165 @@ +@implements IDisposable +@inject MessageLog MessageLog + + + +
+ + + Add a TarkovTracker.org API key. + Tarkov Monitor verifies the key and identifies its game mode before saving it as Unassigned. + +
+ +
+ + API key +
+
+ + Paste the full key exactly as copied from TarkovTracker.org. +
+ @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + @errorMessage + } +
+ + Cancel + + @if (saving) + { + + Verifying + } + else if (cooldownSeconds > 0) + { + @($"Try again in {cooldownSeconds}s") + } + else + { + Verify and save + } + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + [Parameter] public string InitialApiKey { get; set; } = ""; + + private string apiKey = ""; + private string errorMessage = ""; + private Severity messageSeverity = Severity.Error; + private bool saving; + private int cooldownSeconds; + private CancellationTokenSource? cooldownCancellation; + private bool showKey; + private InputType passwordInput = InputType.Password; + private string passwordIcon = Icons.Material.Filled.VisibilityOff; + + protected override void OnInitialized() + { + apiKey = InitialApiKey; + StartCooldownCountdown(); + } + + private void Cancel() => MudDialog.Cancel(); + + private void ToggleKeyVisibility() + { + showKey = !showKey; + passwordInput = showKey ? InputType.Text : InputType.Password; + passwordIcon = showKey ? Icons.Material.Filled.Visibility : Icons.Material.Filled.VisibilityOff; + } + + private async Task Save() + { + saving = true; + errorMessage = ""; + messageSeverity = Severity.Error; + try + { + var importedToken = await TarkovTracker.ImportOrgToken(apiKey); + MudDialog.Close(DialogResult.Ok(importedToken)); + } + catch (Exception ex) + { + errorMessage = ex.Message; + messageSeverity = ex is TarkovTracker.DuplicateImportedTokenException + ? Severity.Info + : Severity.Error; + StartCooldownCountdown(); + if (cooldownSeconds > 0) + { + var report = string.Join(Environment.NewLine, + "TarkovTracker.org API key validation failed.", + $"UTC time: {DateTimeOffset.UtcNow:O}", + $"Tarkov Monitor version: {System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}", + "Operation: Verify and save TarkovTracker.org API key", + "Endpoint: GET https://api.tarkovtracker.org/token", + $"Key type: {TarkovTracker.GetPrefixDisplayName(TarkovTracker.GetTokenPrefix(apiKey))}", + $"Error type: {ex.GetType().Name}", + $"Error: {ex.Message}", + "The API key was intentionally excluded from this report."); + MessageLog.AddMessage(report, "exception"); + errorMessage += " A sanitized error report was added to Messages so you can copy it when reporting the problem."; + } + } + finally + { + saving = false; + } + } + + private void StartCooldownCountdown() + { + cooldownSeconds = TarkovTracker.GetImportValidationCooldownSeconds(); + if (cooldownSeconds <= 0 || cooldownCancellation is { IsCancellationRequested: false }) + { + return; + } + + cooldownCancellation = new CancellationTokenSource(); + _ = RunCooldownCountdown(cooldownCancellation.Token); + } + + private async Task RunCooldownCountdown(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + cooldownSeconds = TarkovTracker.GetImportValidationCooldownSeconds(); + await InvokeAsync(StateHasChanged); + if (cooldownSeconds <= 0) + { + break; + } + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + catch (OperationCanceledException) + { + } + finally + { + cooldownCancellation?.Dispose(); + cooldownCancellation = null; + } + } + + public void Dispose() + { + cooldownCancellation?.Cancel(); + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerAccountNicknameDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerAccountNicknameDialog.razor new file mode 100644 index 0000000..0491236 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerAccountNicknameDialog.razor @@ -0,0 +1,67 @@ + + +
+ + + @(HasNickname ? "Change this account's nickname." : "Set a nickname for this EFT account.") + This nickname identifies Account ID @AccountId and appears on every profile assigned to the account. It does not change your EFT display name. + +
+ +
+ + Account nickname +
+
+ + Use 1-32 characters. You can change it later. +
+ @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + @errorMessage + } +
+ + Cancel + + @(HasNickname ? "Save name" : "Set name") + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] public string ExistingNickname { get; set; } = ""; + [Parameter] public string AccountId { get; set; } = ""; + + private string nickname = ""; + private string errorMessage = ""; + private bool HasNickname => !string.IsNullOrWhiteSpace(ExistingNickname); + + protected override void OnInitialized() + { + nickname = ExistingNickname; + } + + private void Cancel() => MudDialog.Cancel(); + + private void Save() + { + var normalizedNickname = nickname.Trim(); + if (normalizedNickname.Length is < 1 or > 32 || normalizedNickname.Any(char.IsControl)) + { + errorMessage = "Enter a name between 1 and 32 characters."; + return; + } + MudDialog.Close(DialogResult.Ok(normalizedNickname)); + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerProfileNicknameDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerProfileNicknameDialog.razor new file mode 100644 index 0000000..256e6a9 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/SetTarkovTrackerProfileNicknameDialog.razor @@ -0,0 +1,67 @@ + + +
+ + + @(HasNickname ? $"Change this {Mode} profile's nickname." : $"Set a nickname for this {Mode} profile.") + If you reassign this API key, the nickname moves with it. Unbinding the key clears the nickname. It does not change your EFT character name. + +
+ +
+ + Profile nickname +
+
+ + Use 1-32 characters. You can change it while the key remains assigned. +
+ @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + @errorMessage + } +
+ + Cancel + + @(HasNickname ? "Save name" : "Set name") + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] public string ExistingNickname { get; set; } = ""; + [Parameter] public string Mode { get; set; } = ""; + + private string nickname = ""; + private string errorMessage = ""; + private bool HasNickname => !string.IsNullOrWhiteSpace(ExistingNickname); + + protected override void OnInitialized() + { + nickname = ExistingNickname; + } + + private void Cancel() => MudDialog.Cancel(); + + private void Save() + { + var normalizedNickname = nickname.Trim(); + if (normalizedNickname.Length is < 1 or > 32 || normalizedNickname.Any(char.IsControl)) + { + errorMessage = "Enter a name between 1 and 32 characters."; + return; + } + MudDialog.Close(DialogResult.Ok(normalizedNickname)); + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor index e858847..cc41bfb 100644 --- a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor +++ b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor @@ -6,6 +6,7 @@ @inject MessageLog messageLog @inject IDialogService DialogService @inject LocalizationService LocalizationService +@inject MainBlazorUI WindowHost @inject TimersManager timersManager @layout AppLayout @implements IDisposable @@ -134,10 +135,197 @@ @if (!IsRetiredTrackerService) { @LocalizationService.GetString("GetAToken") - @LocalizationService.GetString("TestToken") @LocalizationService.GetString("CurrentProfile"): @CurrentProfileName (@GameWatcher.CurrentProfile.Type) - + Add a TarkovTracker.org API key. Tarkov Monitor verifies the key and its game mode before it can be assigned to an EFT profile. + @foreach (var warning in TrackerStorageWarnings) + { + @warning + } +
+ @if (CanImportOrgKey) + { + Import API Key + } + else + { + + Import API Key + + } + + Scan profiles + +
+ +
+
+ + Tarkov Monitor manages API keys automatically. Only change an assignment if you understand how EFT profiles and game modes are linked. +
+ +
+ + +
+
+ + Verified, but unassigned. Tarkov Monitor assigns a key automatically when it is the only unassigned key for the active game mode. Otherwise, click Assign to choose the correct EFT profile. +
+ @if (PendingOrgKeys.Count == 0) + { + No unassigned API key is stored. + } + @foreach (var key in PendingOrgKeys) + { +
+
+
+
+ @key.DisplayName + Not assigned + + @(key.IsVerified ? "Verified" : "Verify on assign") + +
+ @key.MaskedToken + @if (key.IsLegacyRecovery) + { + Previously saved TarkovTracker.org key + } +
+
+ @if (!OrgKeyChangesEnabled || key.IsQuarantined) + { + + Assign + + } + else + { + Assign + } + @if (OrgKeyChangesEnabled) + { + Remove + } + else + { + + Remove + + } +
+
+ @if (key.HasPendingConflict || key.IsQuarantined) + { + @PendingKeyIssue(key) + } +
+ } +
+
+
+ +
+ + +
+
+ + Verified and assigned. Nothing else is required. Tarkov Monitor uses the key linked to your active EFT account, profile, and game mode. If it was assigned incorrectly—such as when using multiple EFT accounts—open Edit and choose Reassign. Set or rename account and profile nicknames as needed. +
+ @if (BoundOrgKeys.Count == 0) + { + No TarkovTracker.org API keys are assigned. + } + else + { + @if (BoundKeyFilterEnabled) + { + + } + + } +
+
+
+
} @@ -195,6 +383,18 @@ private Dictionary PlaybackDevices; private List<(string Code, string Name)> AvailableLanguages = new(); + private bool BoundKeyFilterEnabled => false; + private string boundKeyFilter = ""; + // Drawer state survives navigation within this process, but is never persisted. + private static bool pendingDrawerExpanded; + private static bool boundDrawerExpanded; + private bool scanningOrgProfiles; + private static readonly HashSet expandedAccountIds = new(StringComparer.Ordinal); + + private sealed record OrgAccountGroup( + string AccountId, + string Nickname, + List Keys); private TarkovDev.Trader? TraderFence { get @@ -511,19 +711,6 @@ } } - //public bool TestTokenButtonDisabled { get; set; } - public bool TestTokenButtonDisabled - { - get - { - return TarkovTracker.IsLegacyService || TarkovTracker.GetToken(GameWatcher.CurrentProfile.Id).Length != 22; - } - set - { - - } - } - public TimeSpan RunthroughTime { get @@ -557,23 +744,6 @@ } private string trackerIcon = ""; - bool tokenShow; - InputType PasswordInput = InputType.Password; - string PasswordInputIcon = Icons.Material.Filled.VisibilityOff; - public string TarkovTrackerToken - { - get - { - return TarkovTracker.IsLegacyService ? "" : TarkovTracker.GetToken(GameWatcher.CurrentProfile.Id); - } - set - { - if (!TarkovTracker.IsLegacyService) - { - TarkovTracker.SetToken(GameWatcher.CurrentProfile.Id, value); - } - } - } public string TarkovTrackerDomain { get @@ -582,35 +752,74 @@ } set { - if (Properties.Settings.Default.tarkovTrackerDomain == value) + var previousDomain = Properties.Settings.Default.tarkovTrackerDomain; + var previousServiceName = TarkovTracker.Domains.TryGetValue(previousDomain, out var serviceName) + ? serviceName + : previousDomain; + if (previousDomain == value) { return; } - Properties.Settings.Default.tarkovTrackerDomain = value; - TarkovTracker.ResetActiveState(); - TarkovTracker.InitAPI(); - Properties.Settings.Default.Save(); - if (!TarkovTracker.IsLegacyService) + + WindowHost.BeginTrackerStatusTransition(); + var switchSucceeded = false; + try { - _ = RevalidateTrackerProfile(); + TarkovTracker.ResetActiveProfile(); + Properties.Settings.Default.tarkovTrackerDomain = value; + TarkovTracker.InitAPI(); + Properties.Settings.Default.Save(); + switchSucceeded = true; + } + catch (Exception switchError) + { + Properties.Settings.Default.tarkovTrackerDomain = previousDomain; + try + { + TarkovTracker.InitAPI(); + Properties.Settings.Default.Save(); + messageLog.AddMessage( + $"Tarkov Tracker service change was canceled and {previousServiceName} was restored: {switchError.Message}", + "warning"); + } + catch (Exception rollbackError) + { + TarkovTracker.ResetActiveProfile(); + messageLog.AddMessage( + $"Tarkov Tracker service change failed and could not be fully restored. Restart Tarkov Monitor before using Tarkov Tracker. Change error: {switchError.Message} Restore error: {rollbackError.Message}", + "exception"); + return; + } + } + finally + { + WindowHost.CompleteTrackerStatusTransition(); + } + + if (switchSucceeded || Properties.Settings.Default.tarkovTrackerDomain == previousDomain) + { + _ = ReactivateCurrentTrackerProfile(); } } } - private async Task RevalidateTrackerProfile() + private async Task ReactivateCurrentTrackerProfile() { - if (string.IsNullOrWhiteSpace(GameWatcher.CurrentProfile.Id)) + if (!eft.IsGameRunning + || !GameWatcher.CurrentProfile.SupportsTarkovTrackerWrites + || string.IsNullOrWhiteSpace(GameWatcher.CurrentProfile.Id)) { + TarkovTracker.DeactivateProfile(); return; } try { - await TarkovTracker.SetProfile(GameWatcher.CurrentProfile.Id); + await TarkovTracker.SetProfile(GameWatcher.CurrentProfile); } catch (Exception ex) { - messageLog.AddMessage(ex.Message, "exception"); + messageLog.AddMessage($"Error changing Tarkov Tracker service: {ex.Message}", "exception"); } } @@ -619,48 +828,522 @@ "tarkovtracker.io", StringComparison.OrdinalIgnoreCase); - private string CurrentProfileUrl => - $"https://tarkov.dev/players/{GameWatcher.CurrentProfile.Type.ToString().ToLower()}/{GameWatcher.CurrentProfile.AccountId}"; + private string CurrentProfileUrl => $"https://tarkov.dev/players/{GameWatcher.CurrentProfile.Type.ToString().ToLower()}/{GameWatcher.CurrentProfile.AccountId}"; + + private IReadOnlyList OrgKeys => TarkovTracker.GetOrgKeys(); + private IReadOnlyList TrackerStorageWarnings => TarkovTracker.GetStorageWarnings(); + private bool OrgKeyChangesEnabled => TarkovTracker.CanChangeOrgKeyStorage; + private List PendingOrgKeys => OrgKeys.Where(key => !key.IsBound).ToList(); + private List BoundOrgKeys => OrgKeys.Where(key => key.IsBound).ToList(); + private const string OrgKeyChangesBlockedReason = "TarkovTracker.org key storage must be repaired before saved keys or profiles can be changed."; + private bool CanImportOrgKey => OrgKeyChangesEnabled + && (!PendingOrgKeys.Any(key => string.Equals(key.Prefix, "PVE", StringComparison.OrdinalIgnoreCase)) + || !PendingOrgKeys.Any(key => string.Equals(key.Prefix, "PVP", StringComparison.OrdinalIgnoreCase)) + || !PendingOrgKeys.Any(key => string.Equals(key.Prefix, "SZN", StringComparison.OrdinalIgnoreCase))); + private string OrgImportBlockedReason => OrgKeyChangesEnabled + ? "PVE, Regular (PVP), and Seasonal already have unassigned keys." + : OrgKeyChangesBlockedReason; + private string OrgProfileScanHelpText => OrgKeyChangesEnabled + ? "Reads account, profile, mode, and last-played information from past EFT logs. It does not replay tasks." + : OrgKeyChangesBlockedReason; + private List BoundOrgKeyGroups + { + get + { + var filter = boundKeyFilter.Trim(); + return BoundOrgKeys + .GroupBy(key => key.AccountId, StringComparer.Ordinal) + .Select(group => new OrgAccountGroup( + group.Key, + group.Select(key => key.AccountNickname).FirstOrDefault(nickname => !string.IsNullOrWhiteSpace(nickname)) ?? "", + group.ToList())) + .Where(group => string.IsNullOrWhiteSpace(filter) + || group.AccountId.Contains(filter, StringComparison.OrdinalIgnoreCase) + || group.Nickname.Contains(filter, StringComparison.OrdinalIgnoreCase) + || group.Keys.Any(key => key.ProfileNickname.Contains(filter, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(group => string.Equals(group.AccountId, GameWatcher.CurrentProfile.AccountId, StringComparison.Ordinal)) + .ThenBy(group => group.AccountId, StringComparer.Ordinal) + .ToList(); + } + } + + private void TogglePendingDrawer() => pendingDrawerExpanded = !pendingDrawerExpanded; + + private string PendingAssignBlockedReason(TarkovTracker.OrgKeySummary key) + { + return OrgKeyChangesEnabled + ? "This saved record must be removed or repaired before assignment." + : OrgKeyChangesBlockedReason; + } - void TokenShowClick() + private static string PendingKeyIssue(TarkovTracker.OrgKeySummary key) { - if (tokenShow) + return key.HasPendingConflict + ? $"Manual assignment required: more than one {key.DisplayName} key is waiting. Automatic assignment is paused for this mode." + : "This key is inactive because its saved record is ambiguous or invalid."; + } + + private void ToggleBoundDrawer() => boundDrawerExpanded = !boundDrawerExpanded; + + private void ToggleAccountDrawer(string accountId) + { + if (!expandedAccountIds.Add(accountId)) { - tokenShow = false; - PasswordInputIcon = Icons.Material.Filled.VisibilityOff; - PasswordInput = InputType.Password; + expandedAccountIds.Remove(accountId); } - else + } + + private bool IsAccountDrawerExpanded(string accountId) + { + return !string.IsNullOrWhiteSpace(boundKeyFilter) || expandedAccountIds.Contains(accountId); + } + + private static string AccountDrawerTitle(OrgAccountGroup accountGroup) + { + return string.IsNullOrWhiteSpace(accountGroup.Nickname) + ? "No nickname" + : accountGroup.Nickname; + } + + private static string AccountNicknameAction(OrgAccountGroup accountGroup) + { + return string.IsNullOrWhiteSpace(accountGroup.Nickname) ? "Set name" : "Rename"; + } + + private static bool CanSetAccountNickname(OrgAccountGroup accountGroup) + { + return accountGroup.Keys.Any(key => !key.IsQuarantined); + } + + private string AccountNicknameBlockedReason(OrgAccountGroup accountGroup) + { + return OrgKeyChangesEnabled + ? "Repair or remove invalid profile bindings before naming this account." + : OrgKeyChangesBlockedReason; + } + + private static string ModeBadgeClass(TarkovTracker.OrgKeySummary key) + { + return key.Prefix.ToUpperInvariant() switch { - tokenShow = true; - PasswordInputIcon = Icons.Material.Filled.Visibility; - PasswordInput = InputType.Text; + "PVE" => "tracker-mode-badge--pve", + "PVP" => "tracker-mode-badge--pvp", + "SZN" => "tracker-mode-badge--seasonal", + _ => "tracker-mode-badge--unknown", + }; + } + + private static string ModeBadgeShortLabel(TarkovTracker.OrgKeySummary key) + { + return key.Prefix.ToUpperInvariant() switch + { + "PVE" => "PVE", + "PVP" => "PVP", + "SZN" => "Seasonal", + _ => "?", + }; + } + + private async Task OpenImportTrackerKey() + { + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync("Import TarkovTracker.org API key", options); + var result = await dialog.Result; + if (result is { Canceled: false, Data: TarkovTracker.ImportedToken importedToken }) + { + messageLog.AddMessage( + $"Imported and verified the {importedToken.DisplayName} TarkovTracker.org API key. It is ready to assign.", + "update"); + await ReactivateCurrentTrackerProfile(); + StateHasChanged(); } } - private async void TestToken() + private async Task ScanOrgProfiles() { - if (!TarkovTracker.IsSupportedOrgToken(TarkovTrackerToken)) + scanningOrgProfiles = true; + try + { + var discovered = await Task.Run(eft.DiscoverProfiles); + var updated = TarkovTracker.SaveDiscoveredOrgProfiles(discovered); + messageLog.AddMessage(discovered.Count == 0 + ? "No EFT profiles were found in past logs. Launch EFT once, then scan again." + : $"Found {discovered.Count} EFT profile{(discovered.Count == 1 ? "" : "s")} in past logs and updated {updated} saved profile record{(updated == 1 ? "" : "s")}.", + discovered.Count == 0 ? "warning" : "update"); + } + catch (Exception ex) + { + messageLog.AddMessage($"Could not scan EFT profiles: {ex.Message}", "exception"); + } + finally + { + scanningOrgProfiles = false; + } + StateHasChanged(); + } + + private async Task OpenAssignOrgKey(TarkovTracker.OrgKeySummary key, bool reassign) + { + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var parameters = new DialogParameters + { + [nameof(AssignTarkovTrackerKeyDialog.KeyPrefix)] = key.Prefix, + [nameof(AssignTarkovTrackerKeyDialog.KeyDisplayName)] = key.DisplayName, + [nameof(AssignTarkovTrackerKeyDialog.KeyIsBound)] = key.IsBound, + [nameof(AssignTarkovTrackerKeyDialog.RequiresVerification)] = !key.IsVerified, + [nameof(AssignTarkovTrackerKeyDialog.BoundAccountId)] = key.AccountId, + [nameof(AssignTarkovTrackerKeyDialog.BoundProfileId)] = key.ProfileId, + [nameof(AssignTarkovTrackerKeyDialog.BoundSessionMode)] = key.SessionMode, + [nameof(AssignTarkovTrackerKeyDialog.ProfileNickname)] = key.ProfileNickname, + [nameof(AssignTarkovTrackerKeyDialog.IsReassign)] = reassign, + }; + var dialog = await DialogService.ShowAsync( + $"{(reassign ? "Reassign" : "Assign")} {key.DisplayName} API key", + parameters, + options); + var result = await dialog.Result; + if (result is not { Canceled: false, Data: TarkovTracker.OrgProfileSummary profile }) { - messageLog.AddMessage("Enter a PVE_, PVP_, or SZN_ TarkovTracker.org key followed by its 18-character hexadecimal identifier.", "warning"); return; - } else { - try + } + + var willSwap = reassign && profile.HasBoundKey; + var action = willSwap ? "Swap" : reassign ? "Reassign" : "Assign"; + try + { + TarkovTracker.OrgKeySummary assignedKey; + if (reassign) { - var tokenResponse = await TarkovTracker.TestToken(TarkovTrackerToken); - if (!tokenResponse.permissions.Contains("WP")) + var reassignment = TarkovTracker.ReassignOrgKey(key.Id, profile.AccountId, profile.ProfileId, profile.SessionMode); + assignedKey = reassignment.Key; + if (reassignment.SwappedKey == null) { - messageLog.AddMessage("You do not have write permissions with this token.", "warning"); - return; + var nicknameNotice = string.IsNullOrWhiteSpace(assignedKey.ProfileNickname) ? "" : " Its profile nickname moved with the key."; + AddProtectedKeyMessage( + $"Reassigned the verified {assignedKey.DisplayName} TarkovTracker.org API key.{nicknameNotice}", + ("Previous Account ID", key.AccountId), + ("Previous Profile ID", key.ProfileId), + ("New Account ID", assignedKey.AccountId), + ("New Profile ID", assignedKey.ProfileId)); + } + else + { + var nicknameNotice = string.IsNullOrWhiteSpace(assignedKey.ProfileNickname) + && string.IsNullOrWhiteSpace(reassignment.SwappedKey.ProfileNickname) + ? "" + : " Profile nicknames moved with their keys."; + AddProtectedKeyMessage( + $"Swapped the verified {assignedKey.DisplayName} TarkovTracker.org API keys between the two saved profiles.{nicknameNotice}", + ("First Account ID", key.AccountId), + ("First Profile ID", key.ProfileId), + ("Second Account ID", assignedKey.AccountId), + ("Second Profile ID", assignedKey.ProfileId)); } } - catch (Exception ex) + else { - messageLog.AddMessage(ex.Message, "exception"); + assignedKey = await TarkovTracker.AssignOrgKey(key.Id, profile.AccountId, profile.ProfileId, profile.SessionMode); + AddProtectedKeyMessage( + $"Assigned the verified {assignedKey.DisplayName} TarkovTracker.org API key manually.", + ("Account ID", assignedKey.AccountId), + ("Profile ID", assignedKey.ProfileId)); + } + + pendingDrawerExpanded = PendingOrgKeys.Count > 0; + boundDrawerExpanded = true; + expandedAccountIds.Add(assignedKey.AccountId); + await ReactivateCurrentTrackerProfile(); + } + catch (Exception ex) + { + messageLog.AddMessage($"Could not {action.ToLowerInvariant()} the TarkovTracker.org API key: {ex.Message}", "exception"); + } + StateHasChanged(); + } + + private async Task SetOrgAccountNickname(OrgAccountGroup accountGroup) + { + var parameters = new DialogParameters + { + [nameof(SetTarkovTrackerAccountNicknameDialog.ExistingNickname)] = accountGroup.Nickname, + [nameof(SetTarkovTrackerAccountNicknameDialog.AccountId)] = accountGroup.AccountId, + }; + var title = string.IsNullOrWhiteSpace(accountGroup.Nickname) ? "Set account nickname" : "Change account nickname"; + var dialog = await DialogService.ShowAsync( + title, + parameters, + new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await dialog.Result; + if (result is not { Canceled: false, Data: string nickname }) + { + return; + } + try + { + var savedNickname = TarkovTracker.SetOrgAccountNickname(accountGroup.AccountId, nickname); + AddProtectedKeyMessage($"Saved the TarkovTracker.org account nickname “{savedNickname}”.", ("Account ID", accountGroup.AccountId)); + } + catch (Exception ex) + { + messageLog.AddMessage($"Could not save the account nickname: {ex.Message}", "exception"); + } + StateHasChanged(); + } + + private async Task SetOrgProfileNickname(TarkovTracker.OrgKeySummary key) + { + var parameters = new DialogParameters + { + [nameof(SetTarkovTrackerProfileNicknameDialog.ExistingNickname)] = key.ProfileNickname, + [nameof(SetTarkovTrackerProfileNicknameDialog.Mode)] = key.DisplayName, + }; + var title = string.IsNullOrWhiteSpace(key.ProfileNickname) + ? $"Set {key.DisplayName} profile nickname" + : $"Change {key.DisplayName} profile nickname"; + var dialog = await DialogService.ShowAsync( + title, + parameters, + new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await dialog.Result; + if (result is not { Canceled: false, Data: string nickname }) + { + return; + } + + try + { + var savedNickname = TarkovTracker.SetOrgProfileNickname(key.Id, nickname); + AddProtectedKeyMessage( + $"Saved the {key.DisplayName} profile nickname “{savedNickname}”.", + ("Account ID", key.AccountId), + ("Profile ID", key.ProfileId)); + } + catch (Exception ex) + { + messageLog.AddMessage($"Could not save the profile nickname: {ex.Message}", "exception"); + } + StateHasChanged(); + } + + private async Task UnbindOrgKey(TarkovTracker.OrgKeySummary key) + { + if (!await ConfirmOrgKeyAction( + key, + $"Unbind {key.DisplayName} API key", + "Keep the key, but stop using it.", + string.IsNullOrWhiteSpace(key.ProfileNickname) + ? "The key will remain saved and move to Unassigned. You can assign it again later." + : "The key will remain saved and move to Unassigned. Its profile nickname will be cleared.", + "Unbind", + Icons.Material.Filled.LinkOff, + false)) + { + return; + } + + try + { + TarkovTracker.UnbindOrgKey(key.Id); + var nicknameNotice = string.IsNullOrWhiteSpace(key.ProfileNickname) ? "" : " Its profile nickname was cleared."; + AddProtectedKeyMessage( + $"Moved the verified {key.DisplayName} TarkovTracker.org API key to Unassigned.{nicknameNotice}", + ("Previous Account ID", key.AccountId), + ("Previous Profile ID", key.ProfileId)); + pendingDrawerExpanded = true; + await ReactivateCurrentTrackerProfile(); + } + catch (Exception ex) + { + messageLog.AddMessage($"Could not unbind the TarkovTracker.org API key: {ex.Message}", "exception"); + } + StateHasChanged(); + } + + private async Task RemoveOrgKey(TarkovTracker.OrgKeySummary key) + { + if (!await ConfirmOrgKeyAction( + key, + $"Remove {key.DisplayName} API key", + "Delete this API key?", + "This removes the key from Tarkov Monitor on this PC. To restore it, you will need to import it again.", + "Remove", + Icons.Material.Filled.DeleteOutline, + true)) + { + return; + } + + try + { + if (!TarkovTracker.RemoveOrgKey(key.Id)) + { + messageLog.AddMessage("This TarkovTracker.org API key has already been removed.", "warning"); + return; + } + if (key.IsBound) + { + AddProtectedKeyMessage( + $"Removed the verified {key.DisplayName} TarkovTracker.org API key from Tarkov Monitor.", + ("Previous Account ID", key.AccountId), + ("Previous Profile ID", key.ProfileId)); } + else + { + var verificationState = key.IsVerified ? "verified" : "unverified recovered"; + messageLog.AddMessage($"Removed the {verificationState} unassigned {key.DisplayName} TarkovTracker.org API key.", "update"); + } + await ReactivateCurrentTrackerProfile(); + } + catch (Exception ex) + { + messageLog.AddMessage($"Could not remove the TarkovTracker.org API key: {ex.Message}", "exception"); + } + StateHasChanged(); + } + + private async Task ConfirmOrgKeyAction( + TarkovTracker.OrgKeySummary key, + string title, + string heading, + string message, + string confirmLabel, + string icon, + bool isDestructive) + { + var parameters = new DialogParameters + { + [nameof(ConfirmTarkovTrackerKeyActionDialog.Heading)] = heading, + [nameof(ConfirmTarkovTrackerKeyActionDialog.Message)] = message, + [nameof(ConfirmTarkovTrackerKeyActionDialog.Mode)] = key.DisplayName, + [nameof(ConfirmTarkovTrackerKeyActionDialog.MaskedToken)] = key.MaskedToken, + [nameof(ConfirmTarkovTrackerKeyActionDialog.AccountId)] = key.AccountId, + [nameof(ConfirmTarkovTrackerKeyActionDialog.ConfirmLabel)] = confirmLabel, + [nameof(ConfirmTarkovTrackerKeyActionDialog.Icon)] = icon, + [nameof(ConfirmTarkovTrackerKeyActionDialog.IsDestructive)] = isDestructive, + }; + var dialog = await DialogService.ShowAsync( + title, + parameters, + new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await dialog.Result; + return result is { Canceled: false, Data: true }; + } + + private async Task EditOrgKey(TarkovTracker.OrgKeySummary key) + { + var savedProfile = TarkovTracker.GetKnownOrgProfiles().FirstOrDefault(profile => + string.Equals(profile.AccountId, key.AccountId, StringComparison.Ordinal) + && string.Equals(profile.ProfileId, key.ProfileId, StringComparison.Ordinal) + && profile.SessionMode == key.SessionMode); + var lastPlayed = savedProfile?.LastSeenUtc is { } lastSeen + ? lastSeen.ToLocalTime().ToString("MMM d, yyyy h:mm tt") + : "Unknown"; + var parameters = new DialogParameters + { + [nameof(EditTarkovTrackerKeyDialog.Mode)] = key.DisplayName, + [nameof(EditTarkovTrackerKeyDialog.MaskedToken)] = key.MaskedToken, + [nameof(EditTarkovTrackerKeyDialog.AccountId)] = key.AccountId, + [nameof(EditTarkovTrackerKeyDialog.ProfileId)] = key.ProfileId, + [nameof(EditTarkovTrackerKeyDialog.ProfileNickname)] = key.ProfileNickname, + [nameof(EditTarkovTrackerKeyDialog.LastPlayed)] = lastPlayed, + [nameof(EditTarkovTrackerKeyDialog.IsQuarantined)] = key.IsQuarantined, + [nameof(EditTarkovTrackerKeyDialog.KeyChangesDisabled)] = !OrgKeyChangesEnabled, + [nameof(EditTarkovTrackerKeyDialog.KeyChangesDisabledReason)] = OrgKeyChangesBlockedReason, + }; + var dialog = await DialogService.ShowAsync( + $"Edit {key.DisplayName} API key", + parameters, + new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + var result = await dialog.Result; + if (result is not { Canceled: false, Data: EditTarkovTrackerKeyDialog.KeyAction action }) + { + return; + } + + switch (action) + { + case EditTarkovTrackerKeyDialog.KeyAction.Reassign: + await OpenAssignOrgKey(key, true); + break; + case EditTarkovTrackerKeyDialog.KeyAction.SetProfileNickname: + await SetOrgProfileNickname(key); + break; + case EditTarkovTrackerKeyDialog.KeyAction.Unbind: + await UnbindOrgKey(key); + break; + case EditTarkovTrackerKeyDialog.KeyAction.Remove: + await RemoveOrgKey(key); + break; } + } + + private async Task ShowOrgAccountDetails(OrgAccountGroup accountGroup) + { + var nickname = string.IsNullOrWhiteSpace(accountGroup.Nickname) ? "Not set" : accountGroup.Nickname; + var modes = string.Join(", ", accountGroup.Keys + .Select(key => key.DisplayName) + .Distinct(StringComparer.Ordinal) + .OrderBy(mode => mode, StringComparer.Ordinal)); + var parameters = new DialogParameters + { + [nameof(TarkovTrackerDetailsDialog.Details)] = new List> + { + new("Account nickname", nickname), + new("Account ID", accountGroup.AccountId), + new("Assigned profiles", accountGroup.Keys.Count.ToString()), + new("Modes", modes), + }, + }; + var dialog = await DialogService.ShowAsync( + "EFT account details", + parameters, + new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }); + await dialog.Result; + } + + private void AddProtectedKeyMessage(string message, params (string Label, string Value)[] protectedValues) + { + messageLog.AddProtectedMessage( + message, + "update", + protectedValues.Select(value => new MonitorMessageProtectedValue(value.Label, value.Value))); + } + private static string CurrentAccountMarker(string accountId) + { + return string.Equals(accountId, GameWatcher.CurrentProfile.AccountId, StringComparison.Ordinal) + ? "• Current account" + : ""; + } + + private static string ActiveKeyMarker(TarkovTracker.OrgKeySummary key) + { + if (!TarkovTracker.IsLegacyService + && TarkovTracker.ValidToken + && string.Equals(key.AccountId, TarkovTracker.CurrentAccountId, StringComparison.Ordinal) + && string.Equals(key.ProfileId, TarkovTracker.CurrentProfileId, StringComparison.Ordinal) + && key.SessionMode == TarkovTracker.CurrentSessionMode) + { + return "• Active profile"; + } + + return string.Equals(key.AccountId, GameWatcher.CurrentProfile.AccountId, StringComparison.Ordinal) + && string.Equals(key.ProfileId, GameWatcher.CurrentProfile.Id, StringComparison.Ordinal) + && key.SessionMode == GameWatcher.CurrentProfile.SessionMode + && string.IsNullOrWhiteSpace(TarkovTracker.GetToken(key.ProfileId)) + ? "• Last active profile" + : ""; + } + + private static string ProfileRowLabel(TarkovTracker.OrgKeySummary key) + { + var marker = ActiveKeyMarker(key); + if (string.IsNullOrWhiteSpace(key.ProfileNickname)) + { + return marker; + } + return string.IsNullOrWhiteSpace(marker) ? key.ProfileNickname : $"{key.ProfileNickname} {marker}"; } private void OpenTrackerSettings() { diff --git a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css new file mode 100644 index 0000000..07142c3 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css @@ -0,0 +1,412 @@ +.tracker-key-manager { + --tracker-border: rgba(255, 255, 255, 0.11); + --tracker-border-hover: rgba(104, 164, 255, 0.42); + --tracker-surface: rgba(255, 255, 255, 0.025); + --tracker-surface-raised: rgba(255, 255, 255, 0.045); + margin-top: 0.42rem; +} + +.tracker-key-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; + margin: 0; + padding: 0 0 0.45rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.085); +} + +.tracker-drawer, +.tracker-account-drawer { + overflow: hidden; + border: 1px solid var(--tracker-border); + background: linear-gradient(135deg, rgba(32, 33, 37, 0.96), rgba(21, 22, 25, 0.96)); + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.tracker-drawer { + border-radius: 13px; +} + +.tracker-account-drawer { + border-radius: 9px; +} + +.tracker-drawer:hover, +.tracker-account-drawer:hover { + border-color: var(--tracker-border-hover); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.tracker-drawer-toggle, +.tracker-account-toggle { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + flex: 1 1 auto; + padding: 0.68rem 0.8rem; + border: 0; + color: inherit; + background: transparent; + text-align: left; + cursor: pointer; +} + +.tracker-drawer-toggle--primary { + width: 100%; + padding: 0.5rem 0.72rem; + background: transparent; +} + +.tracker-drawer-toggle:hover, +.tracker-account-toggle:hover, +.tracker-drawer-toggle:focus-visible, +.tracker-account-toggle:focus-visible { + background-color: rgba(94, 151, 235, 0.09); + outline: none; +} + +.tracker-drawer-toggle:focus-visible, +.tracker-account-toggle:focus-visible { + box-shadow: inset 0 0 0 2px rgba(104, 164, 255, 0.72); +} + +.tracker-chevron { + flex: 0 0 auto; + color: #78aefb; +} + +.tracker-drawer-title, +.tracker-account-title { + overflow: hidden; + font-size: 0.92rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tracker-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.55rem; + height: 1.35rem; + padding: 0 0.38rem; + border: 1px solid rgba(120, 174, 251, 0.35); + border-radius: 999px; + color: #b9d4ff; + background: rgba(72, 126, 206, 0.16); + font-size: 0.68rem; + font-weight: 700; +} + +.tracker-drawer-content { + padding: 0 0.65rem 0.65rem; +} + +.tracker-state-note { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0 0 0.55rem; + padding: 0.48rem 0.58rem; + border: 1px solid rgba(143, 130, 95, 0.35); + border-radius: 8px; + color: rgba(255, 255, 255, 0.76); + background: rgba(143, 130, 95, 0.08); + font-size: 0.72rem; + line-height: 1.35; +} + +.tracker-state-note-icon { + flex: 0 0 auto; + margin-top: 0.03rem; + color: #c2b27e; +} + +.tracker-state-note-icon--bound { + color: #8ce0b8; +} + +.tracker-manager-warning { + margin-bottom: 0.45rem; + padding: 0.36rem 0.55rem; + border-color: rgba(229, 176, 70, 0.42); + background: rgba(159, 112, 28, 0.1); +} + +.tracker-empty-state { + padding: 0.7rem 0.25rem; + color: rgba(255, 255, 255, 0.6); +} + +.tracker-pending-card, +.tracker-profile-row { + padding: 0.48rem 0.62rem; + border: 1px solid var(--tracker-border); + border-radius: 7px; + background: var(--tracker-surface); +} + +.tracker-profile-row { + padding: 0.34rem 0.55rem; +} + +.tracker-pending-card + .tracker-pending-card, +.tracker-profile-row + .tracker-profile-row { + margin-top: 0.35rem; +} + +.tracker-profile-row + .tracker-profile-row { + margin-top: 0.26rem; +} + +.tracker-key-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; +} + +.tracker-key-copy { + min-width: 0; + flex: 1 1 auto; +} + +.tracker-key-actions, +.tracker-account-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 0.05rem; +} + +.tracker-filter { + margin: 0.05rem 0 0.5rem; +} + +.tracker-account-list { + max-height: 360px; + overflow-y: auto; + padding: 0.1rem 0.28rem 0.1rem 0; + scrollbar-color: rgba(120, 174, 251, 0.7) rgba(255, 255, 255, 0.04); + scrollbar-width: thin; +} + +.tracker-account-heading { + display: grid; + grid-template-columns: minmax(11rem, 1fr) 10rem 10.4rem; + align-items: center; + column-gap: 0.35rem; + min-height: 2.65rem; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent); + transition: background-color 160ms ease, box-shadow 160ms ease; +} + +.tracker-account-heading:hover, +.tracker-account-heading:focus-within { + background-color: rgba(94, 151, 235, 0.09); +} + +.tracker-account-heading:focus-within { + box-shadow: inset 0 0 0 2px rgba(104, 164, 255, 0.72); +} + +.tracker-account-drawer { + position: relative; +} + +.tracker-account-drawer::before { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: 0; + width: 2px; + background: linear-gradient(180deg, #78aefb, rgba(139, 92, 246, 0.55)); + content: ""; + opacity: 0.58; + pointer-events: none; +} + +.tracker-account-toggle { + width: 100%; + padding: 0.34rem 0.62rem; +} + +.tracker-account-toggle:hover, +.tracker-account-toggle:focus-visible { + background-color: transparent; + box-shadow: none; +} + +.tracker-account-actions { + display: grid; + width: 10.4rem; + grid-template-columns: 4.35rem 5.8rem; + gap: 0.25rem; + padding-right: 0.35rem; +} + +.tracker-account-action-slot { + display: flex; + min-width: 0; + align-items: center; + justify-content: center; +} + +.tracker-account-copy { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: baseline; + gap: 0.48rem; +} + +.tracker-account-subtitle, +.tracker-token { + overflow: hidden; + color: rgba(255, 255, 255, 0.58); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tracker-account-title { + flex: 0 1 auto; +} + +.tracker-account-subtitle { + flex: 1 1 auto; +} + +.tracker-profile-identity { + display: flex; + min-width: 0; + align-items: center; + gap: 0.45rem; +} + +.tracker-profile-identity .mud-typography { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tracker-profile-identity .tracker-token { + flex: 0 1 auto; +} + +.tracker-mode-summary { + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 0.2rem; +} + +.tracker-mode-badge { + display: inline-flex; + align-items: center; + min-height: 1.22rem; + padding: 0.03rem 0.36rem; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.025em; +} + +.tracker-verification-badge { + display: inline-flex; + align-items: center; + min-height: 1.15rem; + padding: 0.02rem 0.38rem; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.tracker-verification-badge--verified { + border-color: rgba(108, 203, 154, 0.32); + color: rgba(150, 226, 187, 0.92); + background: rgba(55, 137, 94, 0.12); +} + +.tracker-verification-badge--pending { + border-color: rgba(242, 188, 87, 0.38); + color: #f2ca7f; + background: rgba(181, 123, 37, 0.14); +} + +.tracker-mode-badge--pve { + border-color: rgba(87, 194, 142, 0.45); + color: #8ce0b8; + background: rgba(48, 146, 99, 0.15); +} + +.tracker-mode-badge--pvp { + border-color: rgba(111, 166, 255, 0.45); + color: #9fc5ff; + background: rgba(61, 112, 194, 0.16); +} + +.tracker-mode-badge--seasonal { + border-color: rgba(212, 148, 255, 0.46); + color: #deb0ff; + background: rgba(139, 74, 180, 0.16); +} + +.tracker-mode-badge--unknown { + border-color: rgba(255, 255, 255, 0.24); + color: rgba(255, 255, 255, 0.72); + background: rgba(255, 255, 255, 0.06); +} + +.tracker-profile-list { + padding: 0.28rem 0.5rem 0.42rem; + border-top: 1px solid rgba(255, 255, 255, 0.055); +} + +@media (max-width: 640px) { + .tracker-account-heading { + grid-template-columns: minmax(0, 1fr) 10.4rem; + } + + .tracker-mode-summary { + display: none; + } +} + +@media (max-width: 520px) { + .tracker-account-heading, + .tracker-key-main { + grid-template-columns: minmax(0, 1fr); + } + + .tracker-account-actions, + .tracker-key-actions { + justify-content: flex-start; + padding: 0 0.45rem 0.35rem; + } + + .tracker-account-actions { + width: auto; + grid-column: 1; + } + + .tracker-account-heading { + min-height: auto; + } + + .tracker-account-copy, + .tracker-profile-identity { + flex-wrap: wrap; + row-gap: 0.12rem; + } +} diff --git a/TarkovMonitor/Blazor/Pages/Settings/TarkovTrackerDetailsDialog.razor b/TarkovMonitor/Blazor/Pages/Settings/TarkovTrackerDetailsDialog.razor new file mode 100644 index 0000000..3682840 --- /dev/null +++ b/TarkovMonitor/Blazor/Pages/Settings/TarkovTrackerDetailsDialog.razor @@ -0,0 +1,37 @@ + + +
+ + + Account overview. + These details summarize the API keys assigned to this EFT account. + +
+ +
+ + Account details +
+
+ @foreach (var detail in Details) + { +
+
@detail.Key
+
@detail.Value
+
+ } +
+
+ + Close + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] public IReadOnlyList> Details { get; set; } + = Array.Empty>(); + + private void Close() => MudDialog.Close(); +} diff --git a/TarkovMonitor/wwwroot/css/app.css b/TarkovMonitor/wwwroot/css/app.css index 237bc89..645ab3d 100644 --- a/TarkovMonitor/wwwroot/css/app.css +++ b/TarkovMonitor/wwwroot/css/app.css @@ -94,3 +94,276 @@ body { .floating-timer-panel__timer .mud-typography-h5 { font-size: 1.1rem; } + +/* TarkovTracker.org key-management dialogs */ +.mud-dialog.tracker-assignment-dialog, +.mud-dialog.tracker-settings-dialog { + position: relative; + overflow: hidden; + border: 1px solid rgba(194, 178, 126, 0.72); + border-radius: 14px; + background: #1b1c1f; + box-shadow: 0 20px 56px rgba(0, 0, 0, 0.62), 0 0 0 1px rgba(0, 0, 0, 0.42); +} + +.mud-dialog.tracker-assignment-dialog { + max-height: calc(100vh - 72px); +} + +.mud-dialog.tracker-assignment-dialog::before, +.mud-dialog.tracker-settings-dialog::before { + position: absolute; + z-index: 2; + top: 0; + right: 0; + left: 0; + height: 2px; + background: linear-gradient(90deg, #68a4ff, #7ed7ad 58%, #c2b27e); + content: ""; + pointer-events: none; +} + +.mud-dialog.tracker-assignment-dialog .mud-dialog-title, +.mud-dialog.tracker-settings-dialog .mud-dialog-title { + padding: 13px 18px 11px; + border-bottom: 1px solid rgba(194, 178, 126, 0.28); + background: linear-gradient(180deg, rgba(194, 178, 126, 0.09), rgba(255, 255, 255, 0.015)); + color: #c2b27e; +} + +.mud-dialog.tracker-assignment-dialog .mud-dialog-content { + min-height: 0; + overflow: hidden; + padding: 13px 18px 10px; +} + +.mud-dialog.tracker-settings-dialog .mud-dialog-content { + min-height: 0; + padding: 16px 18px; +} + +.mud-dialog.tracker-assignment-dialog .mud-dialog-actions, +.mud-dialog.tracker-settings-dialog .mud-dialog-actions { + min-height: 52px; + padding: 8px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(0, 0, 0, 0.16); +} + +.mud-dialog.tracker-edit-key-dialog .mud-dialog-content { + padding: 8px 18px 7px; +} + +.mud-dialog.tracker-edit-key-dialog .mud-dialog-actions { + min-height: 46px; + padding: 4px 10px; +} + +.tracker-dialog-note { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.62rem; + align-items: flex-start; + margin-bottom: 0.85rem; + padding: 0.66rem 0.72rem; + border: 1px solid rgba(111, 166, 255, 0.3); + border-radius: 10px; + color: rgba(255, 255, 255, 0.76); + background: linear-gradient(135deg, rgba(61, 112, 194, 0.12), rgba(255, 255, 255, 0.018)); + font-size: 0.76rem; + line-height: 1.38; +} + +.tracker-dialog-note--assigned { + border-color: rgba(87, 194, 142, 0.32); + background: linear-gradient(135deg, rgba(48, 146, 99, 0.12), rgba(255, 255, 255, 0.018)); +} + +.tracker-dialog-note--warning { + border-color: rgba(242, 188, 87, 0.36); + background: linear-gradient(135deg, rgba(181, 123, 37, 0.14), rgba(255, 255, 255, 0.018)); +} + +.tracker-dialog-note--danger { + border-color: rgba(242, 105, 105, 0.38); + background: linear-gradient(135deg, rgba(181, 57, 57, 0.15), rgba(255, 255, 255, 0.018)); +} + +.tracker-dialog-note__icon { color: #9fc5ff; } +.tracker-dialog-note--assigned .tracker-dialog-note__icon { color: #8ce0b8; } +.tracker-dialog-note--warning .tracker-dialog-note__icon { color: #f2ca7f; } +.tracker-dialog-note--danger .tracker-dialog-note__icon { color: #ff9b9b; } + +.tracker-dialog-note__copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.1rem; +} + +.tracker-dialog-note__copy strong { + color: rgba(255, 255, 255, 0.94); + font-size: 0.79rem; +} + +.tracker-dialog-section-heading { + display: flex; + align-items: center; + gap: 0.38rem; + margin: 0 0 0.42rem; + color: rgba(255, 255, 255, 0.82); + font-size: 0.73rem; + font-weight: 750; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.tracker-dialog-detail-card { + display: flex; + overflow: hidden; + flex-direction: column; + margin: 0; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 11px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.038), rgba(0, 0, 0, 0.11)); +} + +.tracker-dialog-detail-row { + display: grid; + grid-template-columns: 7.25rem minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + min-height: 1.95rem; + padding: 0.26rem 0.62rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.065); +} + +.tracker-dialog-detail-row:last-child { border-bottom: 0; } +.tracker-dialog-detail-row dt { color: rgba(255, 255, 255, 0.55); font-size: 0.72rem; font-weight: 650; } +.tracker-dialog-detail-row dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: rgba(255, 255, 255, 0.92); font-size: 0.8rem; font-weight: 600; } + +.tracker-dialog-mode-badge { + display: inline-flex; + align-items: center; + min-height: 1.2rem; + padding: 0.02rem 0.4rem; + border: 1px solid rgba(111, 166, 255, 0.42); + border-radius: 999px; + color: #a9cbff; + background: rgba(61, 112, 194, 0.14); + font-size: 0.67rem; + font-weight: 750; +} + +.tracker-dialog-mode-badge--pve { border-color: rgba(87, 194, 142, 0.42); color: #8ce0b8; background: rgba(48, 146, 99, 0.14); } +.tracker-dialog-mode-badge--seasonal { border-color: rgba(212, 148, 255, 0.42); color: #deb0ff; background: rgba(139, 74, 180, 0.14); } + +.tracker-dialog-form-card { + padding: 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 11px; + background: rgba(255, 255, 255, 0.022); +} + +.tracker-dialog-field-help { + display: block; + margin-top: 0.42rem; + color: rgba(255, 255, 255, 0.55); + font-size: 0.7rem; + line-height: 1.35; +} + +.tracker-key-action-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.tracker-key-action { + display: flex; + min-width: 0; + min-height: 4.45rem; + align-items: flex-start; + gap: 0.52rem; + padding: 0.54rem; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 10px; + color: rgba(255, 255, 255, 0.88); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.038), rgba(0, 0, 0, 0.1)); + font: inherit; + text-align: left; + cursor: pointer; +} + +.tracker-key-action:hover, +.tracker-key-action:focus-visible { border-color: rgba(111, 166, 255, 0.55); background-color: rgba(61, 112, 194, 0.12); outline: none; } +.tracker-key-action:disabled { cursor: not-allowed; opacity: 0.48; } +.tracker-key-action--warning:hover, +.tracker-key-action--warning:focus-visible { border-color: rgba(242, 188, 87, 0.5); background-color: rgba(181, 123, 37, 0.11); } +.tracker-key-action--danger:hover, +.tracker-key-action--danger:focus-visible { border-color: rgba(242, 105, 105, 0.52); background-color: rgba(181, 57, 57, 0.12); } +.tracker-key-action__icon { color: #9fc5ff; } +.tracker-key-action--warning .tracker-key-action__icon { color: #f2ca7f; } +.tracker-key-action--danger .tracker-key-action__icon { color: #ff9b9b; } +.tracker-key-action__copy { display: flex; min-width: 0; flex-direction: column; gap: 0.16rem; } +.tracker-key-action__title { font-size: 0.76rem; font-weight: 750; } +.tracker-key-action__description { color: rgba(255, 255, 255, 0.56); font-size: 0.67rem; line-height: 1.28; } +.mud-dialog .tracker-dialog-footer-button { min-width: 5.6rem; border-radius: 8px; } + +/* Message IDs stay masked unless the user deliberately hovers or focuses them. */ +.dashboard-message__protected-values { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.55rem; + margin-top: 0.4rem; +} + +.dashboard-message__protected-detail { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 0.35rem; + color: rgba(255, 255, 255, 0.68); + font-size: 0.72rem; +} + +.dashboard-message__protected-label { font-weight: 650; } + +.dashboard-message__protected-value { + display: inline-grid; + max-width: 100%; + min-height: 1.45rem; + align-items: center; + padding: 0.08rem 0.42rem; + overflow: hidden; + border: 1px solid rgba(194, 178, 126, 0.38); + border-radius: 999px; + color: #d7c996; + background: rgba(0, 0, 0, 0.2); + cursor: help; + outline: none; +} + +.dashboard-message__protected-mask, +.dashboard-message__protected-reveal { grid-area: 1 / 1; } + +.dashboard-message__protected-reveal { + visibility: hidden; + overflow-wrap: anywhere; + color: rgba(255, 255, 255, 0.9); + font-family: Consolas, monospace; + font-size: 0.7rem; + user-select: text; +} + +.dashboard-message__protected-value:hover .dashboard-message__protected-mask, +.dashboard-message__protected-value:focus .dashboard-message__protected-mask { visibility: hidden; } + +.dashboard-message__protected-value:hover .dashboard-message__protected-reveal, +.dashboard-message__protected-value:focus .dashboard-message__protected-reveal { visibility: visible; } + +@media (max-width: 520px) { + .tracker-dialog-detail-row { grid-template-columns: 1fr; gap: 0.08rem; align-items: flex-start; } + .tracker-key-action-grid { grid-template-columns: 1fr; } + .tracker-key-action { min-height: auto; } +} From 7407d5643a977cbadda9d10f4d24e28e16e4e88f Mon Sep 17 00:00:00 2001 From: Giribaldi_TTV Date: Mon, 10 Aug 2026 14:11:06 -0700 Subject: [PATCH 03/17] bugfix(raw-logs): replay task history chronologically and in profile scope Process historical notifications in timestamp order with an isolated watcher and exact account, profile, and game-mode boundaries. Require a matching TarkovTracker.org profile lease before writes, stop cleanly when EFT is active or no valid key is assigned, restore subscriptions in finally paths, and keep replay messages batched, bounded, and visually ordered. --- .../Blazor/Components/LogBoard.razor | 10 +- .../Blazor/Components/MessageBoard.razor | 57 ++++- .../Pages/RawLogs/CustomLogDialog.razor | 6 +- .../Pages/RawLogs/ForceReadDialog.razor | 237 +++++++++++++----- .../Blazor/Pages/RawLogs/RawLogs.razor | 12 +- TarkovMonitor/GameWatcher.cs | 72 +++--- TarkovMonitor/MessageLog.cs | 30 +++ TarkovMonitor/MonitorMessage.cs | 2 + 8 files changed, 311 insertions(+), 115 deletions(-) diff --git a/TarkovMonitor/Blazor/Components/LogBoard.razor b/TarkovMonitor/Blazor/Components/LogBoard.razor index a23ee95..d7f2433 100644 --- a/TarkovMonitor/Blazor/Components/LogBoard.razor +++ b/TarkovMonitor/Blazor/Components/LogBoard.razor @@ -1,9 +1,10 @@ @using Humanizer +@implements IDisposable @inject LogRepository logRepository - + @if (logRepository.Logs.Count == 0) { - No new logs have been seen since launching the monitor. + No new log entries since Tarkov Monitor started. }else{ @foreach (LogLine line in logRepository.Logs) { @@ -35,4 +36,9 @@ { InvokeAsync(() => StateHasChanged()); } + + public void Dispose() + { + logRepository.newLog -= LogAdded; + } } diff --git a/TarkovMonitor/Blazor/Components/MessageBoard.razor b/TarkovMonitor/Blazor/Components/MessageBoard.razor index fa36605..84d2d4a 100644 --- a/TarkovMonitor/Blazor/Components/MessageBoard.razor +++ b/TarkovMonitor/Blazor/Components/MessageBoard.razor @@ -54,11 +54,11 @@ }
-
+
@foreach (MonitorMessageSelect select in message.Selects.GetSnapshot()) { - + @foreach (MonitorMessageSelectOption option in select.Options) { @option.Text @@ -95,7 +95,7 @@ // timestamp refreshes. Listen to the source directly so the card redraws // as soon as a message is recorded. messageLog.newMessage += MessageLog_NewMessage; - messages = messageLog.GetSnapshot(); + messages = PrepareForReverseStack(messageLog.GetSnapshot()); timer = new Timer(RefreshCallback, null, 60000, 60000); } @@ -105,11 +105,47 @@ // InvokeAsync safely schedules the redraw on the correct dispatcher. _ = InvokeAsync(() => { - messages = messageLog.GetSnapshot(); + messages = PrepareForReverseStack(messageLog.GetSnapshot()); StateHasChanged(); }); } + private static IReadOnlyList PrepareForReverseStack(IReadOnlyList snapshot) + { + if (snapshot.Count < 2) + { + return snapshot; + } + + var displayItems = new List(snapshot.Count); + for (var index = 0; index < snapshot.Count;) + { + var message = snapshot[index]; + if (!message.PreserveDisplayBatchOrder || message.DisplayBatchId == null) + { + displayItems.Add(message); + index++; + continue; + } + + var batchEnd = index + 1; + while (batchEnd < snapshot.Count + && snapshot[batchEnd].PreserveDisplayBatchOrder + && snapshot[batchEnd].DisplayBatchId == message.DisplayBatchId) + { + batchEnd++; + } + + for (var batchIndex = batchEnd - 1; batchIndex >= index; batchIndex--) + { + displayItems.Add(snapshot[batchIndex]); + } + index = batchEnd; + } + + return displayItems; + } + private string GetTypeIcon(string type) { switch (type) @@ -197,19 +233,24 @@ } catch (Exception ex) { - messageLog.AddMessage($"Error in message button: ${ex.Message} {ex.StackTrace}", "exception"); + messageLog.AddMessage($"Could not activate the message button: {ex.Message} {ex.StackTrace}", "exception"); } } - private async void MessageSelectChanged(MonitorMessageSelect select, IEnumerable selected) + private void MessageSelectChanged(MonitorMessageSelect select, IEnumerable? selected) { try { - select.ChangeSelection(selected.First()); + MonitorMessageSelectOption? option = selected?.FirstOrDefault(); + if (option == null) + { + return; + } + select.ChangeSelection(option); } catch (Exception ex) { - messageLog.AddMessage($"Error in message select: ${ex.Message} {ex.StackTrace}", "exception"); + messageLog.AddMessage($"Could not update the message selection: {ex.Message} {ex.StackTrace}", "exception"); } } diff --git a/TarkovMonitor/Blazor/Pages/RawLogs/CustomLogDialog.razor b/TarkovMonitor/Blazor/Pages/RawLogs/CustomLogDialog.razor index d11ded5..dc9d520 100644 --- a/TarkovMonitor/Blazor/Pages/RawLogs/CustomLogDialog.razor +++ b/TarkovMonitor/Blazor/Pages/RawLogs/CustomLogDialog.razor @@ -3,13 +3,13 @@ - + @foreach (GameLogType item in Enum.GetValues(typeof(GameLogType))) { @item } - + @LocalizationService.GetString("Cancel") @@ -31,4 +31,4 @@ eft.GameWatcher_NewLogData(this, new NewLogDataEventArgs { Type = gameLogType, Data = sampleText }); MudDialog.Close(DialogResult.Ok(true)); } -} \ No newline at end of file +} diff --git a/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor b/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor index 51afe8b..2a35ceb 100644 --- a/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor +++ b/TarkovMonitor/Blazor/Pages/RawLogs/ForceReadDialog.razor @@ -1,25 +1,40 @@ @using System.ComponentModel @using Humanizer +@implements IDisposable +@inject GameWatcher eft @inject MessageLog messageLog @inject LocalizationService LocalizationService - + @LocalizationService.GetString("CurrentProfile"): - @CurrentProfileName (@GameWatcher.CurrentProfile.Type) + @if (HasCurrentProfileRoute) + { + @CurrentProfileName (@GetProfileTypeDisplayName(GameWatcher.CurrentProfile.Type)) + } + else + { + No EFT profile detected. + } @LocalizationService.GetString("IfYouWantToUpdateProgressForDifferentProfile") + @if (eft.IsGameRunning) + { + + Read Past Logs is unavailable while Escape from Tarkov is running. Close EFT before importing historical task updates. + + } @if (customWatcher.LogsPath != "") { if (breakpoints != null) { - + @foreach (LogDetails breakpoint in breakpoints) { - @breakpoint.Version | @breakpoint.Date.ToLongDateString() - @breakpoint.Date.Humanize() + @breakpoint.Profile.DisplayName | @breakpoint.Version | @breakpoint.Date.ToLongDateString() - @breakpoint.Date.Humanize() }

@LocalizationService.GetString("SelectStartingPointDescription")

@@ -37,14 +52,22 @@ }
- @LocalizationService.GetString("Cancel") - @LocalizationService.GetString("Ok") + @LocalizationService.GetString("Cancel") + @LocalizationService.GetString("Ok")
@code { [CascadingParameter] IMudDialogInstance MudDialog { get; set; } - internal GameWatcher customWatcher = new(); + private static string GetProfileTypeDisplayName(ProfileType profileType) => profileType switch + { + ProfileType.PVE => "PVE", + ProfileType.Regular => "PVP", + ProfileType.PvpSeason => "Seasonal PVP", + _ => profileType.ToString(), + }; + + internal GameWatcher customWatcher = new(historicalReplay: true); LogDetails? _selectedBreakpoint; LogDetails? selectedBreakpoint { get @@ -59,9 +82,18 @@ } List? breakpoints; Dictionary TaskStatuses = new(); + Dictionary TaskSequence = new(); + int nextTaskSequence; bool SubmitDisabled { get; set; } = true; + bool IsProcessing { get; set; } - private string currentProfileName = GameWatcher.CurrentProfile.AccountId; + private string currentProfileName = GameWatcher.CurrentProfile.HasTarkovDevPlayerRoute + ? GameWatcher.CurrentProfile.AccountId + : ""; + private bool HasCurrentProfileRoute => GameWatcher.CurrentProfile.HasTarkovDevPlayerRoute; + private string CurrentProfileUrl => HasCurrentProfileRoute + ? $"https://tarkov.dev/players/{GameWatcher.CurrentProfile.TarkovDevPlayerMode}/{GameWatcher.CurrentProfile.AccountId}" + : ""; private string CurrentProfileName { get @@ -69,32 +101,33 @@ return currentProfileName; } } - private Task UpdateProfileName() + private async Task UpdateProfileName() { - return TarkovDev.GetPlayerName(GameWatcher.CurrentProfile).ContinueWith(t => + var profile = GameWatcher.CurrentProfile.Snapshot(); + if (!profile.HasTarkovDevPlayerRoute) { - if (!t.IsCompletedSuccessfully) - { - return; - } - currentProfileName = t.Result; - }); + currentProfileName = ""; + return; + } + + currentProfileName = profile.AccountId; + var profileName = await TarkovDev.GetPlayerName(profile); + if (GameWatcher.CurrentProfile.HasTarkovDevPlayerRoute + && string.Equals(GameWatcher.CurrentProfile.AccountId, profile.AccountId, StringComparison.Ordinal) + && string.Equals(GameWatcher.CurrentProfile.Id, profile.Id, StringComparison.Ordinal) + && GameWatcher.CurrentProfile.SessionMode == profile.SessionMode) + { + currentProfileName = profileName; + } } protected override void OnInitialized() { base.OnInitialized(); Task.Run(GetBreakPoints); - UpdateProfileName(); + _ = UpdateProfileName(); - Properties.Settings.Default.PropertyChanged += (object? sender, PropertyChangedEventArgs e) => - { - if (e.PropertyName == "customLogsPath") - { - customWatcher.LogsPath = Properties.Settings.Default.customLogsPath; - Task.Run(GetBreakPoints); - } - }; + Properties.Settings.Default.PropertyChanged += Settings_PropertyChanged; } private void GetBreakPoints() @@ -105,52 +138,83 @@ void Cancel() => MudDialog.Cancel(); - public async void Submit() + private async Task Submit() { - // Check if a path was selected, and if so, load the logs from that path - if (selectedBreakpoint == null) + var breakpoint = selectedBreakpoint; + if (breakpoint == null || IsProcessing) { return; } - if (!TarkovTracker.ValidToken) + if (eft.IsGameRunning) { - messageLog.AddMessage("You must have a valid Tarkov Tracker API token to read past logs.", "exception"); + messageLog.AddMessage( + "Read Past Logs was not started because Escape from Tarkov is running. Close EFT before importing historical task updates.", + "warning"); + return; } - SubmitDisabled = true; - GameWatcher.ReadingPastLogs = true; - TaskStatuses.Clear(); + if (!breakpoint.Profile.HasIdentity || !breakpoint.Profile.SupportsTarkovTrackerWrites) + { + messageLog.AddMessage( + $"Tarkov Tracker does not support {breakpoint.Profile.DisplayName} historical task updates. No updates were sent.", + "warning"); + return; + } + if (TarkovTracker.IsLegacyService) + { + messageLog.AddMessage( + "Read Past Logs requires TarkovTracker.org. Switch the Tarkov Tracker service in Settings and try again.", + "warning"); + return; + } + if (string.IsNullOrWhiteSpace(TarkovTracker.GetTokenForProfile(breakpoint.Profile))) + { + messageLog.AddMessage( + $"A verified {breakpoint.Profile.DisplayName} TarkovTracker.org API key assigned to this exact EFT profile is required to read past logs.", + "warning"); + return; + } + + TarkovTracker.ProfileActivationLease? trackerLease = null; + var handlerAttached = false; + var taskMessages = new List(); + var updateTasks = new Dictionary(); var totalTasks = 0; - Dictionary updateTasks = new(); + IsProcessing = true; + SubmitDisabled = true; + await InvokeAsync(StateHasChanged); try { - //eft.ProcessLogs(selectedPath); - customWatcher.TaskModified += UpdateTaskStatus; - Profile currentProfile = new() + trackerLease = await TarkovTracker.AcquireProfileLease(breakpoint.Profile); + if (eft.IsGameRunning) { - Id = GameWatcher.CurrentProfile.Id, - Type = GameWatcher.CurrentProfile.Type, - AccountId = GameWatcher.CurrentProfile.AccountId, - SessionMode = GameWatcher.CurrentProfile.SessionMode, - }; - customWatcher.ProcessLogsFromBreakpoint(selectedBreakpoint); - GameWatcher.CurrentProfile = currentProfile; - customWatcher.TaskModified -= UpdateTaskStatus; - foreach (var kvp in TaskStatuses) + messageLog.AddMessage( + "Read Past Logs was cancelled because Escape from Tarkov started before historical processing began.", + "warning"); + return; + } + + GameWatcher.ReadingPastLogs = true; + TaskStatuses.Clear(); + TaskSequence.Clear(); + nextTaskSequence = 0; + customWatcher.TaskModified += UpdateTaskStatus; + handlerAttached = true; + customWatcher.ProcessLogsFromBreakpoint(breakpoint); + + foreach (var kvp in TaskStatuses.OrderBy(item => TaskSequence[item.Key])) { if (kvp.Value == TarkovMonitor.TaskStatus.Started) { - // don't update task status if started continue; } var task = TarkovDev.Tasks.Find((t) => t.id == kvp.Key); if (task == null) { - // probably a daily continue; } totalTasks++; TarkovMonitor.TaskStatus savedTaskStatus = TarkovMonitor.TaskStatus.None; - var taskProgress = TarkovTracker.Progress.data.tasksProgress.Find((prog) => prog.id == kvp.Key); + var taskProgress = trackerLease.Progress.data.tasksProgress.Find((prog) => prog.id == kvp.Key); if (taskProgress != null) { if (taskProgress.failed) @@ -164,40 +228,83 @@ } if (kvp.Value == savedTaskStatus) { - // status matches, so don't update continue; } updateTasks.Add(kvp.Key, kvp.Value); - //System.Diagnostics.Debug.WriteLine($"Task {kvp.Key} should be {kvp.Value}"); - messageLog.AddMessage($"{kvp.Value} task {task.name}", "quest", $"https://tarkov.dev/task/{task.normalizedName}"); + taskMessages.Add(new MonitorMessage( + $"{kvp.Value} task: {task.name}.", + "quest", + $"https://tarkov.dev/task/{task.normalizedName}")); + } + + if (eft.IsGameRunning) + { + messageLog.AddMessage( + "Read Past Logs was cancelled because Escape from Tarkov started before task updates were sent.", + "warning"); + return; + } + if (updateTasks.Count > 0) + { + await TarkovTracker.SetTaskStatuses(updateTasks, trackerLease); + messageLog.AddMessages(taskMessages, preserveDisplayOrder: true); + messageLog.AddMessage( + $"Updated {updateTasks.Count} of {totalTasks} tasks found in the selected logs.", + "info"); } + else + { + messageLog.AddMessage( + $"Checked {totalTasks} tasks in the selected logs. Tarkov Tracker was already up to date.", + "info"); + } + MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - messageLog.AddMessage($"Error compiling task updates: {ex.Message}", "exception"); - return; + messageLog.AddMessage($"Could not read past logs: {ex.Message}", "exception"); } finally { + if (handlerAttached) + { + customWatcher.TaskModified -= UpdateTaskStatus; + } GameWatcher.ReadingPastLogs = false; - } - try - { - if (updateTasks.Count > 0) + if (trackerLease != null) { - await TarkovTracker.SetTaskStatuses(updateTasks); + TarkovTracker.ReleaseProfileLease(trackerLease); } - messageLog.AddMessage($"Of {totalTasks} tasks found in logs, {updateTasks.Count} require status updates in Tarkov Tracker.", "info"); - MudDialog.Close(DialogResult.Ok(true)); - } - catch (Exception ex) - { - messageLog.AddMessage($"Error updating tasks: {ex.Message}", "exception"); + IsProcessing = false; + SubmitDisabled = selectedBreakpoint == null; + await InvokeAsync(StateHasChanged); } } private void UpdateTaskStatus(object? sender, LogContentEventArgs e) { + if (selectedBreakpoint == null + || !string.Equals(e.Profile.AccountId, selectedBreakpoint.Profile.AccountId, StringComparison.Ordinal) + || !string.Equals(e.Profile.Id, selectedBreakpoint.Profile.Id, StringComparison.Ordinal) + || e.Profile.SessionMode != selectedBreakpoint.Profile.SessionMode) + { + return; + } TaskStatuses[e.LogContent.TaskId] = e.LogContent.Status; + TaskSequence[e.LogContent.TaskId] = nextTaskSequence++; + } + + private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == "customLogsPath") + { + customWatcher.LogsPath = Properties.Settings.Default.customLogsPath; + Task.Run(GetBreakPoints); + } + } + + public void Dispose() + { + Properties.Settings.Default.PropertyChanged -= Settings_PropertyChanged; } } diff --git a/TarkovMonitor/Blazor/Pages/RawLogs/RawLogs.razor b/TarkovMonitor/Blazor/Pages/RawLogs/RawLogs.razor index a7631dd..564735a 100644 --- a/TarkovMonitor/Blazor/Pages/RawLogs/RawLogs.razor +++ b/TarkovMonitor/Blazor/Pages/RawLogs/RawLogs.razor @@ -16,10 +16,10 @@ @if (DebugMode() || LogsFolderSet()) { -
- +
+ - + @if(DebugMode()) @@ -46,13 +46,13 @@ private void OpenCustomDialog() { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.ExtraLarge, FullWidth = true }; - DialogService.ShowAsync("Process Custom Log Data", options); + DialogService.ShowAsync("Process custom log data", options); } private void OpenForceReadDialog() { - var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.ExtraLarge, FullWidth = true }; - DialogService.ShowAsync("Read Past Log Files", options); + var options = new DialogOptions { CloseOnEscapeKey = false, BackdropClick = false, MaxWidth = MaxWidth.ExtraLarge, FullWidth = true }; + DialogService.ShowAsync("Read past log files", options); } private void OpenLogsFolder() diff --git a/TarkovMonitor/GameWatcher.cs b/TarkovMonitor/GameWatcher.cs index 8844d43..45cd9f6 100644 --- a/TarkovMonitor/GameWatcher.cs +++ b/TarkovMonitor/GameWatcher.cs @@ -16,6 +16,9 @@ internal class GameWatcher private readonly System.Timers.Timer processTimer; private readonly FileSystemWatcher logFileCreateWatcher; private readonly FileSystemWatcher screenshotWatcher; + private readonly bool historicalReplay; + private Profile parsingProfile = new(); + private Profile ActiveProfile => historicalReplay ? parsingProfile : CurrentProfile; private string _logsPath = ""; public static Profile CurrentProfile { get; set; } = new(); public static bool ReadingPastLogs = false; @@ -179,8 +182,9 @@ public static string GetDefaultLogsFolder() throw new Exception("No Tarkov install path found"); } - public GameWatcher() + public GameWatcher(bool historicalReplay = false) { + this.historicalReplay = historicalReplay; Monitors = new(); raidInfo = new RaidInfo(); logFileCreateWatcher = new FileSystemWatcher @@ -653,18 +657,18 @@ internal void GameWatcher_NewLogData(object? sender, NewLogDataEventArgs e) if (systemMessageEvent.message.type >= MessageType.TaskStarted && systemMessageEvent.message.type <= MessageType.TaskFinished) { var args = jsonNode?.AsObject().Deserialize() ?? throw new Exception("Error parsing TaskStatusMessageLogContent"); - TaskModified?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskModified?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = ActiveProfile }); if (args.Status == TaskStatus.Started) { - TaskStarted?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskStarted?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = ActiveProfile }); } if (args.Status == TaskStatus.Failed) { - TaskFailed?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskFailed?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = ActiveProfile }); } if (args.Status == TaskStatus.Finished) { - TaskFinished?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = CurrentProfile }); + TaskFinished?.Invoke(this, new LogContentEventArgs() { LogContent = args, Profile = ActiveProfile }); } } } @@ -705,10 +709,12 @@ public Dictionary GetLogFolders() // Process the log files in the specified folder public void ProcessLogs(LogDetails target, List profiles) { + profiles = profiles.OrderBy(profile => profile.Date).ToList(); for (var i = 0; i < profiles.Count; i++) { var logProfile = profiles[i]; - if (logProfile.Profile.Id != target.Profile.Id) + if (logProfile.Profile.Id != target.Profile.Id + || logProfile.Profile.SessionMode != target.Profile.SessionMode) { continue; } @@ -717,30 +723,19 @@ public void ProcessLogs(LogDetails target, List profiles) { endDate = profiles[i + 1].Date; } + var startDate = logProfile.Date > target.Date ? logProfile.Date : target.Date; + if (endDate <= startDate) + { + continue; + } + parsingProfile = logProfile.Profile.Snapshot(); var logFiles = Directory.GetFiles(logProfile.Folder); - // TODO: This could be improved by processing lines in the order they were created - // rather than a full file at a time, this could be valuable for future features + var replayEntries = new List<(DateTime Date, string Data)>(); foreach (string logFile in logFiles) { - GameLogType logType; - // Check which type of log file this is by the filename - if (logFile.Contains("application.log") || logFile.Contains("application_000.log")) - { - logType = GameLogType.Application; - } - else if (logFile.Contains("notifications.log") || logFile.Contains("notifications_000.log")) - { - logType = GameLogType.Notifications; - } - else if (logFile.Contains("traces.log") || logFile.Contains("traces_000.log")) + if (!logFile.Contains("notifications.log") + && !logFile.Contains("notifications_000.log")) { - // logType = GameLogType.Traces; - // Traces are not currently used, so skip them - continue; - } - else - { - // We're not a known log type, so skip this file continue; } @@ -756,14 +751,22 @@ public void ProcessLogs(LogDetails target, List profiles) var dateTimeString = match.Groups["date"].Value + " " + match.Groups["time"].Value; DateTime logMessageDate = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); - if (logMessageDate < logProfile.Date || logMessageDate >= endDate) + if (logMessageDate < startDate || logMessageDate >= endDate) { continue; } - GameWatcher_NewLogData(this, new NewLogDataEventArgs { Type = logType, Data = match.Value }); + replayEntries.Add((logMessageDate, match.Value)); } } + foreach (var replayEntry in replayEntries.OrderBy(entry => entry.Date)) + { + GameWatcher_NewLogData(this, new NewLogDataEventArgs + { + Type = GameLogType.Notifications, + Data = replayEntry.Data, + }); + } } } @@ -891,18 +894,24 @@ public List GetLogBreakpoints(string profileId) { continue; } - var matchingBreakpoint = breakpoints.Where((bp) => bp.Version == breakpoint.Version && bp.Profile.Id == breakpoint.Profile.Id).FirstOrDefault(); + var matchingBreakpoint = breakpoints.Where((bp) => bp.Version == breakpoint.Version + && bp.Profile.Id == breakpoint.Profile.Id + && bp.Profile.SessionMode == breakpoint.Profile.SessionMode).FirstOrDefault(); if (matchingBreakpoint == null) { breakpoints.Add(breakpoint); } } } - return breakpoints; + return breakpoints.OrderBy(breakpoint => breakpoint.Date).ToList(); } public void ProcessLogsFromBreakpoint(LogDetails breakpoint) { + if (!historicalReplay) + { + throw new InvalidOperationException("Past logs must be processed by an isolated historical watcher."); + } List> logDetails = new(); var logFolders = Directory.GetDirectories(LogsPath); // For each log folder, get the details @@ -913,7 +922,8 @@ public void ProcessLogsFromBreakpoint(LogDetails breakpoint) { continue; } - if (!details.Any(d => d.Profile.Id == breakpoint.Profile.Id)) + if (!details.Any(d => d.Profile.Id == breakpoint.Profile.Id + && d.Profile.SessionMode == breakpoint.Profile.SessionMode)) { continue; } diff --git a/TarkovMonitor/MessageLog.cs b/TarkovMonitor/MessageLog.cs index c8f1642..5803bd2 100644 --- a/TarkovMonitor/MessageLog.cs +++ b/TarkovMonitor/MessageLog.cs @@ -54,6 +54,36 @@ public void AddMessage(string message, string? type = "", string? url = null, st AddMessageCore(monMessage); } + public void AddMessages(IEnumerable messageBatch, bool preserveDisplayOrder = false) + { + var batch = messageBatch.ToList(); + if (batch.Count == 0) + { + return; + } + + var batchId = preserveDisplayOrder ? Guid.NewGuid() : (Guid?)null; + foreach (var message in batch) + { + message.Message = LimitMessageLength(message.Message); + message.DisplayBatchId = batchId; + message.PreserveDisplayBatchOrder = preserveDisplayOrder; + } + + lock (messagesLock) + { + messages.AddRange(batch); + if (messages.Count > MaxMessages) + { + messages.RemoveRange(0, messages.Count - MaxMessages); + } + } + + // A historical replay can produce many task messages at once. Notify once + // after the complete batch is visible so the UI performs one stable render. + newMessage(this, new NewLogMessageArgs(batch[^1])); + } + public void AddProtectedMessage( string message, string? type, diff --git a/TarkovMonitor/MonitorMessage.cs b/TarkovMonitor/MonitorMessage.cs index 20c9a65..0ed1de2 100644 --- a/TarkovMonitor/MonitorMessage.cs +++ b/TarkovMonitor/MonitorMessage.cs @@ -81,6 +81,8 @@ public void Clear() public class MonitorMessage { + internal Guid? DisplayBatchId { get; set; } + internal bool PreserveDisplayBatchOrder { get; set; } public string Message { get; set; } public DateTime Time { get; set; } = DateTime.Now; public string Type { get; set; } = ""; From 1664912b55495ec17c7db5dad38476584db13d9f Mon Sep 17 00:00:00 2001 From: Giribaldi_TTV Date: Mon, 10 Aug 2026 14:12:40 -0700 Subject: [PATCH 04/17] bugfix(settings): handle missing EFT profile identity Stop empty startup profile state from rendering a false PVP identity, a broken tarkov.dev player link, or an unnecessary player-name lookup. Refresh the displayed profile only for the same account, profile, and session mode, and preserve the reconciled responsive Settings layout, service guidance, and profile-safe controls. --- .../Blazor/Pages/Settings/Settings.razor | 230 ++++++++++++------ 1 file changed, 149 insertions(+), 81 deletions(-) diff --git a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor index cc41bfb..38da093 100644 --- a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor +++ b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor @@ -6,17 +6,49 @@ @inject MessageLog messageLog @inject IDialogService DialogService @inject LocalizationService LocalizationService -@inject MainBlazorUI WindowHost @inject TimersManager timersManager +@inject MainBlazorUI WindowHost @layout AppLayout @implements IDisposable - + + + @LocalizationService.GetString("Language") +
+ + @foreach (var language in AvailableLanguages) + { + @language.Name + } + +
+
+
+ + - @LocalizationService.GetString("Notifications") + @LocalizationService.GetString("WindowBehavior") +
+ +
+
+ +
+
+ +
- + +
+
+
+ + + + @LocalizationService.GetString("Notifications") +
+ @foreach (var device in PlaybackDevices) { @device.Value @@ -24,33 +56,33 @@
+ -
+ -
+ -
+ -
+ -
+ -
+ - - + @if (TraderFence != null) { @foreach (TarkovDev.TraderReputationLevel lvl in TraderFence.reputationLevels) @@ -61,52 +93,20 @@
- +
- + - Timers - - Shows a movable timer inside Tarkov Monitor after deployment finishes. It is hidden while viewing the Timers tab. + Timers + + Shows a movable timer in Tarkov Monitor after deployment finishes. It stays hidden on the Timers page. Displayed timers - At least one timer must remain selected. Scav cooldown is not currently available in the floating panel. - - - - - - @LocalizationService.GetString("Language") -
- - @foreach (var language in AvailableLanguages) - { - @language.Name - } - -
-
-
- - - - @LocalizationService.GetString("WindowBehavior") -
- -
-
- -
-
- -
-
- -
+ Keep at least one timer selected. Scav cooldown is not available in the floating panel.
@@ -117,10 +117,10 @@ - - - @LocalizationService.GetString("TarkovTracker") - + + + @LocalizationService.GetString("TarkovTracker") + @foreach (var pair in TarkovTracker.Domains) { @pair.Value @@ -136,7 +136,14 @@ { @LocalizationService.GetString("GetAToken") @LocalizationService.GetString("CurrentProfile"): - @CurrentProfileName (@GameWatcher.CurrentProfile.Type) + @if (HasCurrentProfileRoute) + { + @CurrentProfileName (@GetProfileTypeDisplayName(GameWatcher.CurrentProfile.Type)) + } + else + { + No EFT profile detected. + } Add a TarkovTracker.org API key. Tarkov Monitor verifies the key and its game mode before it can be assigned to an EFT profile. @foreach (var warning in TrackerStorageWarnings) { @@ -330,9 +337,9 @@ - + - @LocalizationService.GetString("TarkovDevWebsiteRemote") + @TarkovDevWebsiteRemoteTitle
@@ -343,7 +350,7 @@
- + @LocalizationService.GetString("None") @foreach (var map in TarkovDev.Maps) { @@ -353,10 +360,10 @@ - + - @LocalizationService.GetString("LogsFolder")@LocalizationService.GetString("DontChangeThisUnlessYouReallyKnowWhatYoureDoing") - + @LocalizationService.GetString("LogsFolder")@LocalizationService.GetString("DontChangeThisUnlessYouReallyKnowWhatYoureDoing") + @if (Properties.Settings.Default.customLogsPath != null && Properties.Settings.Default.customLogsPath != "") {
@@ -366,11 +373,11 @@ - + - @LocalizationService.GetString("InitialSetup") + @LocalizationService.GetString("InitialSetup")
- @LocalizationService.GetString("ReadPastLogs") + @LocalizationService.GetString("ReadPastLogs")
@@ -403,7 +410,35 @@ } } - private string currentProfileName = GameWatcher.CurrentProfile.AccountId; + private string currentProfileName = GameWatcher.CurrentProfile.HasTarkovDevPlayerRoute + ? GameWatcher.CurrentProfile.AccountId + : ""; + + private RenderFragment TarkovDevWebsiteRemoteTitle => builder => + { + var sequence = 0; + var title = LocalizationService.GetString("TarkovDevWebsiteRemote"); + const string linkText = "Tarkov.dev"; + var linkStart = title.IndexOf(linkText, StringComparison.OrdinalIgnoreCase); + if (linkStart < 0) + { + builder.AddContent(sequence++, title); + return; + } + + builder.AddContent(sequence++, title[..linkStart]); + builder.OpenElement(sequence++, "a"); + builder.AddAttribute(sequence++, "href", "https://tarkov.dev"); + builder.AddAttribute(sequence++, "target", "_blank"); + builder.AddAttribute(sequence++, "rel", "noopener noreferrer"); + builder.AddAttribute(sequence++, "class", "tm-section-link"); + builder.AddContent(sequence++, title.Substring(linkStart, linkText.Length)); + builder.CloseElement(); + builder.OpenElement(sequence++, "span"); + builder.AddAttribute(sequence++, "class", "tm-section-suffix"); + builder.AddContent(sequence++, title[(linkStart + linkText.Length)..].TrimStart()); + builder.CloseElement(); + }; private string CurrentProfileName { get { @@ -411,16 +446,32 @@ } } - private Task UpdateProfileName() + private static string GetProfileTypeDisplayName(ProfileType profileType) => profileType switch { - return TarkovDev.GetPlayerName(GameWatcher.CurrentProfile).ContinueWith(t => + ProfileType.PVE => "PVE", + ProfileType.Regular => "PVP", + ProfileType.PvpSeason => "Seasonal PVP", + _ => profileType.ToString(), + }; + + private async Task UpdateProfileName() + { + var profile = GameWatcher.CurrentProfile.Snapshot(); + if (!profile.HasTarkovDevPlayerRoute) { - if (!t.IsCompletedSuccessfully) - { - return; - } - currentProfileName = t.Result; - }); + currentProfileName = ""; + return; + } + + currentProfileName = profile.AccountId; + var profileName = await TarkovDev.GetPlayerName(profile); + if (GameWatcher.CurrentProfile.HasTarkovDevPlayerRoute + && string.Equals(GameWatcher.CurrentProfile.AccountId, profile.AccountId, StringComparison.Ordinal) + && string.Equals(GameWatcher.CurrentProfile.Id, profile.Id, StringComparison.Ordinal) + && GameWatcher.CurrentProfile.SessionMode == profile.SessionMode) + { + currentProfileName = profileName; + } } protected override void OnInitialized() @@ -429,7 +480,9 @@ PlaybackDevices = Sound.GetPlaybackDevices(); AvailableLanguages = LocalizationService.GetAvailableLanguages(); LocalizationService.LanguageChanged += OnLanguageChanged; - UpdateProfileName(); + eft.GameStarted += OnGameClientStateChanged; + eft.ProfileChanged += OnGameClientStateChanged; + _ = UpdateProfileName(); } protected override void OnParametersSet() @@ -449,6 +502,17 @@ public void Dispose() { LocalizationService.LanguageChanged -= OnLanguageChanged; + eft.GameStarted -= OnGameClientStateChanged; + eft.ProfileChanged -= OnGameClientStateChanged; + } + + private void OnGameClientStateChanged(object? sender, EventArgs e) + { + _ = InvokeAsync(async () => + { + await UpdateProfileName(); + StateHasChanged(); + }); } private void OnLanguageChanged(object? sender, EventArgs e) @@ -468,7 +532,7 @@ if (Properties.Settings.Default.language != value) { LocalizationService.SetCulture(value); - messageLog.AddMessage("Please restart this application to complete the language change.", "warning"); + messageLog.AddMessage("Restart Tarkov Monitor to finish applying the language change.", "warning"); //Properties.Settings.Default.language = value; // handled by LocalizationService.SetCulture //Properties.Settings.Default.Save(); //InvokeAsync(StateHasChanged); // should be unnecessary since we're handling the OnLanguageChanged event @@ -739,8 +803,8 @@ void ReadPastLogs() { - var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.ExtraLarge, FullWidth = true }; - DialogService.ShowAsync("Read Past Log Files", options); + var options = new DialogOptions { CloseOnEscapeKey = false, BackdropClick = false, MaxWidth = MaxWidth.ExtraLarge, FullWidth = true }; + DialogService.ShowAsync("Read past log files", options); } private string trackerIcon = ""; @@ -828,7 +892,11 @@ "tarkovtracker.io", StringComparison.OrdinalIgnoreCase); - private string CurrentProfileUrl => $"https://tarkov.dev/players/{GameWatcher.CurrentProfile.Type.ToString().ToLower()}/{GameWatcher.CurrentProfile.AccountId}"; + private bool HasCurrentProfileRoute => GameWatcher.CurrentProfile.HasTarkovDevPlayerRoute; + + private string CurrentProfileUrl => HasCurrentProfileRoute + ? $"https://tarkov.dev/players/{GameWatcher.CurrentProfile.TarkovDevPlayerMode}/{GameWatcher.CurrentProfile.AccountId}" + : ""; private IReadOnlyList OrgKeys => TarkovTracker.GetOrgKeys(); private IReadOnlyList TrackerStorageWarnings => TarkovTracker.GetStorageWarnings(); @@ -1453,7 +1521,7 @@ } catch (Exception ex) { - messageLog.AddMessage($"Error getting default logs folder: {ex.Message}", "exception"); + messageLog.AddMessage($"Could not find the default logs folder: {ex.Message}", "exception"); } return ""; } @@ -1471,7 +1539,7 @@ var currentFolder = CustomLogsFolder; using var dialog = new System.Windows.Forms.FolderBrowserDialog { - Description = "Select the Escape from Tarkov logs folder.", + Description = "Select your Escape from Tarkov logs folder.", UseDescriptionForTitle = true, ShowNewFolderButton = false, SelectedPath = Directory.Exists(currentFolder) ? currentFolder : "", @@ -1484,7 +1552,7 @@ } catch (Exception ex) { - messageLog.AddMessage($"Error opening logs folder picker: {ex.Message}", "exception"); + messageLog.AddMessage($"Could not open the logs-folder picker: {ex.Message}", "exception"); } } From ea9f3a351ae734991d27967c2f14470f148dcf41 Mon Sep 17 00:00:00 2001 From: Giribaldi_TTV Date: Mon, 10 Aug 2026 14:13:01 -0700 Subject: [PATCH 05/17] bugfix(startup): stabilize first paint and window restoration Upgrade persisted settings before startup reads, render the WebView shell before watcher and update services, and coordinate the optional splash with the first completed UI paint. Suppress live-session notices for offline historical profiles, deduplicate exact session announcements, and let native window state transitions restore the frame, title-bar drag behavior, and prior bounds without flicker. --- TarkovMonitor/Blazor/AppLayout.razor | 51 +++- TarkovMonitor/MainBlazorUI.cs | 333 ++++++++++++++++++++------- TarkovMonitor/Program.cs | 64 ++++- TarkovMonitor/Splash.cs | 81 +++++-- 4 files changed, 416 insertions(+), 113 deletions(-) diff --git a/TarkovMonitor/Blazor/AppLayout.razor b/TarkovMonitor/Blazor/AppLayout.razor index 2a4c175..1fac101 100644 --- a/TarkovMonitor/Blazor/AppLayout.razor +++ b/TarkovMonitor/Blazor/AppLayout.razor @@ -21,11 +21,11 @@
- +
- +
- @if (key.HasPendingConflict || key.IsQuarantined) + @if (key.IsAutoBindBlocked || key.HasPendingConflict || key.IsQuarantined) { @PendingKeyIssue(key) } @@ -484,6 +490,7 @@ LocalizationService.LanguageChanged += OnLanguageChanged; ResolveCustomLogsFolder(); eft.GameStarted += OnGameClientStateChanged; + eft.GameStopped += OnGameClientStateChanged; eft.ProfileChanged += OnGameClientStateChanged; _ = UpdateProfileName(); } @@ -506,6 +513,7 @@ { LocalizationService.LanguageChanged -= OnLanguageChanged; eft.GameStarted -= OnGameClientStateChanged; + eft.GameStopped -= OnGameClientStateChanged; eft.ProfileChanged -= OnGameClientStateChanged; } @@ -954,6 +962,11 @@ private static string PendingKeyIssue(TarkovTracker.OrgKeySummary key) { + if (key.IsAutoBindBlocked) + { + return "MANUAL ASSIGNMENT ONLY: automatic assignment is blocked for the EFT account that previously owned this key. Use Assign to choose its account, profile, and game mode."; + } + return key.HasPendingConflict ? $"Manual assignment required: more than one {key.DisplayName} key is waiting. Automatic assignment is paused for this mode." : "This key is inactive because its saved record is ambiguous or invalid."; @@ -1028,7 +1041,9 @@ if (result is { Canceled: false, Data: TarkovTracker.ImportedToken importedToken }) { messageLog.AddMessage( - $"Imported and verified the {importedToken.DisplayName} TarkovTracker.org API key. It is ready to assign.", + importedToken.IsBound + ? $"Imported, verified, and automatically assigned the {importedToken.DisplayName} TarkovTracker.org API key to the active EFT profile." + : $"Imported and verified the {importedToken.DisplayName} TarkovTracker.org API key. It will auto-assign on the first matching EFT profile, or you can assign it manually.", "update"); await ReactivateCurrentTrackerProfile(); StateHasChanged(); @@ -1209,8 +1224,8 @@ $"Unbind {key.DisplayName} API key", "Keep the key, but stop using it.", string.IsNullOrWhiteSpace(key.ProfileNickname) - ? "The key will remain saved and move to Unassigned. You can assign it again later." - : "The key will remain saved and move to Unassigned. Its profile nickname will be cleared.", + ? "The key will remain saved and move to Unassigned and will be marked MANUAL ASSIGNMENT ONLY for its previous EFT account. Assign it explicitly to use it again." + : "The key will remain saved and move to Unassigned. Its profile nickname will be cleared, and it will be marked MANUAL ASSIGNMENT ONLY for its previous EFT account.", "Unbind", Icons.Material.Filled.LinkOff, false)) diff --git a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css index 07142c3..992b6d0 100644 --- a/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css +++ b/TarkovMonitor/Blazor/Pages/Settings/Settings.razor.css @@ -344,6 +344,26 @@ background: rgba(181, 123, 37, 0.14); } +.tracker-assignment-badge { + display: inline-flex; + align-items: center; + min-height: 1.15rem; + padding: 0.02rem 0.42rem; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.66rem; + font-weight: 800; + letter-spacing: 0.035em; + line-height: 1.2; + white-space: nowrap; +} + +.tracker-assignment-badge--manual { + border-color: rgba(255, 112, 112, 0.62); + color: #ffd0d0; + background: rgba(177, 47, 47, 0.24); +} + .tracker-mode-badge--pve { border-color: rgba(87, 194, 142, 0.45); color: #8ce0b8; From 28841691f83142cc069dfd1844809862c42542d1 Mon Sep 17 00:00:00 2001 From: Giribaldi_TTV Date: Thu, 13 Aug 2026 06:09:36 -0700 Subject: [PATCH 14/17] fix(tarkov.dev): make session data lifecycle identity-safe Preload read-only Tarkov.dev assets from the most recent complete profile recovered from EFT log history, allowing startup verification without launching EFT when prior profile data exists. Publish assets only for the exact account, profile, and session context; cancel stale loads, clear old snapshots on transitions and invalid profiles, refresh replacement application logs through ProfileReady, stop updates on EFT exit, and keep timer registration idempotent. Tracker writes still require a live EFT profile. --- TarkovMonitor/GameWatcher.cs | 26 +++++- TarkovMonitor/MainBlazorUI.cs | 162 +++++++++++++++++++++++++++------- TarkovMonitor/TarkovDev.cs | 60 +++++++++++-- 3 files changed, 204 insertions(+), 44 deletions(-) diff --git a/TarkovMonitor/GameWatcher.cs b/TarkovMonitor/GameWatcher.cs index 6bc32de..a6c0292 100644 --- a/TarkovMonitor/GameWatcher.cs +++ b/TarkovMonitor/GameWatcher.cs @@ -188,6 +188,7 @@ public string ScreenshotsPath public event EventHandler? ExceptionThrown; public event EventHandler? DebugMessage; public event EventHandler? GameStarted; + public event EventHandler? GameStopped; public event EventHandler>? GroupInviteAccept; public event EventHandler>? GroupRaidSettings; public event EventHandler>? GroupMemberReady; @@ -213,6 +214,7 @@ public string ScreenshotsPath public event EventHandler? PlayerPosition; public event EventHandler ProfileChanged; public event EventHandler InitialReadComplete; + public event EventHandler? ProfileReady; public event EventHandler ControlSettings; private static string logPatternPrefix = @"(?^\d{4}-\d{2}-\d{2}) (?