-
-
Notifications
You must be signed in to change notification settings - Fork 114
Add a web label #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Add a web label #883
Changes from all commits
57f3f18
bcd8122
57065e1
3335b2c
dc37f28
1dd82b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,321 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Net; | ||
| using System.Text; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| using Rampastring.Tools; | ||
|
|
||
| namespace ClientCore; | ||
|
|
||
| /// <summary> | ||
| /// Manages shared JSON data sources for XNAClientJSONLabel. | ||
| /// Data sources are defined in INI files under [DataSources] section. | ||
| /// </summary> | ||
| public class JSONDataSourceManager | ||
| { | ||
| private static JSONDataSourceManager _instance; | ||
| public static JSONDataSourceManager Instance => _instance ??= new JSONDataSourceManager(); | ||
|
|
||
| private readonly Dictionary<string, DataSource> _dataSources = new Dictionary<string, DataSource>(); | ||
|
|
||
| private JSONDataSourceManager() { } | ||
|
|
||
| /// <summary> | ||
| /// Loads data sources from an INI file's [DataSources] section. | ||
| /// </summary> | ||
| /// <param name="iniFile">The INI file to read from</param> | ||
| public void LoadFromINI(IniFile iniFile) | ||
| { | ||
| const string sectionName = "DataSources"; | ||
| var section = iniFile.GetSection(sectionName); | ||
|
|
||
| if (section == null) | ||
| return; | ||
|
|
||
| foreach (var key in section.Keys) | ||
| { | ||
| string value = section.GetStringValue(key.Key, string.Empty); | ||
| if (string.IsNullOrEmpty(value)) | ||
| continue; | ||
|
|
||
| // format: URL,RefreshIntervalSeconds,TimeoutSeconds,Format | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. URL could have commas in it, I think instead should do something like [DataSourceName]
URL=https://...
RefreshIntervalSeconds=Also I think individual data source parsing should be in the DataSource itself? |
||
| string[] parts = value.Split(','); | ||
| if (parts.Length < 1) | ||
| continue; | ||
|
|
||
| string url = parts[0].Trim(); | ||
| int refreshInterval = parts.Length > 1 ? Conversions.IntFromString(parts[1].Trim(), 300) : 300; | ||
| int timeout = parts.Length > 2 ? Conversions.IntFromString(parts[2].Trim(), 10) : 10; | ||
| string format = parts.Length > 3 ? parts[3].Trim().ToLowerInvariant() : "json"; | ||
|
|
||
| if (string.IsNullOrEmpty(url) || _dataSources.ContainsKey(key.Key)) | ||
| continue; | ||
|
|
||
| var dataSource = new DataSource(key.Key, url, refreshInterval, timeout, format); | ||
| _dataSources.Add(key.Key, dataSource); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Subscribes to a data source and receives JSON updates. | ||
| /// </summary> | ||
| /// <param name="dataSourceId">The ID of the data source</param> | ||
| /// <param name="callback">Callback invoked when new data is available (json, isError)</param> | ||
| /// <returns>True if subscription was successful, false if data source not found</returns> | ||
| public bool Subscribe(string dataSourceId, Action<string, bool> callback) | ||
| { | ||
| if (string.IsNullOrEmpty(dataSourceId)) | ||
| return false; | ||
|
|
||
| if (!_dataSources.TryGetValue(dataSourceId, out var dataSource)) | ||
| return false; | ||
|
|
||
| dataSource.Subscribe(callback); | ||
| return true; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Unsubscribes from a data source. | ||
| /// </summary> | ||
| public void Unsubscribe(string dataSourceId, Action<string, bool> callback) | ||
| { | ||
| if (string.IsNullOrEmpty(dataSourceId)) | ||
| return; | ||
|
|
||
| if (_dataSources.TryGetValue(dataSourceId, out var dataSource)) | ||
| dataSource.Unsubscribe(callback); | ||
| } | ||
|
|
||
| private class DataSource | ||
| { | ||
| private readonly string _id; | ||
| private readonly string _url; | ||
| private readonly int _refreshIntervalSeconds; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does it run even in background or when controls using it is not active? although I realise now that it will probably complicate the code a lot, so I don't think there's a big need in doing it exactly. but if there is a lot of players that don't go online -- maybe autorefresh isn't something we may want to do? it will strain our servers quite a bit, maybe we should update just when the control is shown for the first time? (or sth in this direction) |
||
| private readonly int _timeoutSeconds; | ||
| private readonly string _format; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with an interface. So we can decouple some string parsing logic from the UI logic. My recent editing experience on LAN lobby shows that it might waste a huge amount of time to maintain codes if different kinds of logics are not decoupled |
||
| private readonly List<Action<string, bool>> _subscribers = new List<Action<string, bool>>(); | ||
| private CancellationTokenSource _cts; | ||
| private Task _fetchTask; | ||
| private string _lastJson; | ||
|
|
||
| public DataSource(string id, string url, int refreshIntervalSeconds, int timeoutSeconds, string format) | ||
| { | ||
| _id = id; | ||
| _url = url; | ||
| _refreshIntervalSeconds = refreshIntervalSeconds; | ||
| _timeoutSeconds = timeoutSeconds; | ||
| _format = format; | ||
| } | ||
|
|
||
| public void Subscribe(Action<string, bool> callback) | ||
| { | ||
| lock (_subscribers) | ||
| { | ||
| if (!_subscribers.Contains(callback)) | ||
| { | ||
| _subscribers.Add(callback); | ||
|
|
||
| if (_lastJson != null) // cached | ||
| callback(_lastJson, false); | ||
|
|
||
| if (_subscribers.Count == 1 && _fetchTask == null) | ||
| StartFetching(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public void Unsubscribe(Action<string, bool> callback) | ||
| { | ||
| lock (_subscribers) | ||
| { | ||
| _subscribers.Remove(callback); | ||
|
|
||
| if (_subscribers.Count == 0) | ||
| StopFetching(); | ||
| } | ||
| } | ||
|
|
||
| private void StartFetching() | ||
| { | ||
| _cts = new CancellationTokenSource(); | ||
| _fetchTask = Task.Run(() => FetchLoopAsync(_cts.Token)); | ||
| } | ||
|
|
||
| private void StopFetching() | ||
| { | ||
| _cts?.Cancel(); | ||
| _fetchTask = null; | ||
| } | ||
|
|
||
| private async Task FetchLoopAsync(CancellationToken token) | ||
| { | ||
| while (!token.IsCancellationRequested) | ||
| { | ||
| try | ||
| { | ||
| await FetchAndNotifyAsync(token); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Logger.Log($"JSONDataSourceManager: Error fetching '{_id}': {ex.Message}"); | ||
| NotifySubscribers(null, true); | ||
| } | ||
|
|
||
| if (_refreshIntervalSeconds <= 0) | ||
| break; | ||
|
|
||
| try | ||
| { | ||
| await Task.Delay(TimeSpan.FromSeconds(_refreshIntervalSeconds), token); | ||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async Task FetchAndNotifyAsync(CancellationToken token) | ||
| { | ||
| if (token.IsCancellationRequested) | ||
| return; | ||
|
|
||
| string data = await DownloadWithTimeout(_url, _timeoutSeconds, token); | ||
|
|
||
| if (data == null) | ||
| { | ||
| NotifySubscribers(null, true); | ||
| return; | ||
| } | ||
|
|
||
| if (_format == "ini") | ||
| { | ||
| try | ||
| { | ||
| data = ConvertIniToJson(data); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Logger.Log($"JSONDataSourceManager: Error converting INI to JSON for '{_id}': {ex.Message}"); | ||
| NotifySubscribers(null, true); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| _lastJson = data; | ||
| NotifySubscribers(data, false); | ||
| } | ||
|
|
||
| private string ConvertIniToJson(string iniContent) | ||
| { | ||
| IniFile iniFile; | ||
| using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(iniContent))) | ||
| { | ||
| iniFile = new IniFile(stream, applyBaseIni: false); | ||
| } | ||
|
|
||
| var sb = new StringBuilder(); | ||
| sb.Append("{"); | ||
| bool firstSection = true; | ||
|
Comment on lines
+220
to
+222
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The aforementioned bringing of JSON parsing earlier than that will allow you to construct the programmatic representation via a few foreaches, iterating sections and then keys to set values and greatly simplifying the code 🙂 |
||
|
|
||
| foreach (var sectionName in iniFile.GetSections()) | ||
| { | ||
| var section = iniFile.GetSection(sectionName); | ||
|
|
||
| if (!firstSection) | ||
| sb.Append(","); | ||
| firstSection = false; | ||
|
|
||
| sb.Append($"\"{EscapeJsonString(section.SectionName)}\":{{"); | ||
|
|
||
| bool firstKey = true; | ||
| foreach (var key in section.Keys) | ||
| { | ||
| if (!firstKey) | ||
| sb.Append(","); | ||
| firstKey = false; | ||
|
|
||
| string value = section.GetStringValue(key.Key, string.Empty); | ||
|
|
||
| if (int.TryParse(value, out int intValue)) | ||
| sb.Append($"\"{EscapeJsonString(key.Key)}\":{intValue}"); | ||
| else if (double.TryParse(value, out double doubleValue)) | ||
| sb.Append($"\"{EscapeJsonString(key.Key)}\":{doubleValue}"); | ||
| else | ||
| sb.Append($"\"{EscapeJsonString(key.Key)}\":\"{EscapeJsonString(value)}\""); | ||
| } | ||
|
|
||
| sb.Append("}"); | ||
| } | ||
|
|
||
| sb.Append("}"); | ||
| return sb.ToString(); | ||
| } | ||
|
|
||
| private string EscapeJsonString(string str) | ||
| { | ||
| if (string.IsNullOrEmpty(str)) | ||
| return str; | ||
|
|
||
| return str.Replace("\\", "\\\\") | ||
| .Replace("\"", "\\\"") | ||
| .Replace("\n", "\\n") | ||
| .Replace("\r", "\\r") | ||
| .Replace("\t", "\\t"); | ||
| } | ||
|
|
||
| private void NotifySubscribers(string json, bool isError) | ||
| { | ||
| lock (_subscribers) | ||
| { | ||
| foreach (var subscriber in _subscribers) | ||
| { | ||
| try | ||
| { | ||
| subscriber(json, isError); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Logger.Log($"JSONDataSourceManager: Error notifying subscriber for '{_id}': {ex.Message}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async Task<string> DownloadWithTimeout(string url, int timeoutSeconds, CancellationToken token) | ||
| { | ||
| using (var cts = new CancellationTokenSource()) | ||
| using (var webClient = new WebClient()) | ||
| { | ||
| var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), cts.Token); | ||
| var downloadTask = webClient.DownloadStringTaskAsync(url); | ||
|
|
||
| var completed = await Task.WhenAny(downloadTask, timeoutTask); | ||
|
|
||
| if (completed == timeoutTask) | ||
| { | ||
| Logger.Log($"JSONDataSourceManager: Timeout for '{_id}' ({url})"); | ||
| return null; | ||
| } | ||
|
|
||
| cts.Cancel(); | ||
|
|
||
| if (token.IsCancellationRequested) | ||
| return null; | ||
|
|
||
| try | ||
| { | ||
| return await downloadTask; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Logger.Log($"JSONDataSourceManager: Download error for '{_id}': {ex.Message}"); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have a DI container in use, I think you could use that? The codebase haven't fully migrated though, so tell me if you encounter any issues with that