diff --git a/ArchiSteamFarm/FixedSizeConcurrentQueue.cs b/ArchiSteamFarm/FixedSizeConcurrentQueue.cs new file mode 100644 index 0000000000000..7b7dedce3c22e --- /dev/null +++ b/ArchiSteamFarm/FixedSizeConcurrentQueue.cs @@ -0,0 +1,63 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// +// Copyright 2015-2018 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace ArchiSteamFarm { + internal sealed class FixedSizeConcurrentQueue : IEnumerable { + private readonly ConcurrentQueue BackingQueue = new ConcurrentQueue(); + + internal byte MaxCount { + get => _MaxCount; + set { + _MaxCount = value; + + while ((BackingQueue.Count > MaxCount) && BackingQueue.TryDequeue(out _)) { } + } + } + + private byte _MaxCount; + + internal FixedSizeConcurrentQueue(byte maxCount) { + if (maxCount == 0) { + throw new ArgumentNullException(nameof(maxCount)); + } + + MaxCount = maxCount; + } + + public IEnumerator GetEnumerator() => BackingQueue.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + internal void Enqueue(T obj) { + BackingQueue.Enqueue(obj); + + if (BackingQueue.Count <= MaxCount) { + return; + } + + BackingQueue.TryDequeue(out _); + } + } +} \ No newline at end of file diff --git a/ArchiSteamFarm/HistoryTarget.cs b/ArchiSteamFarm/HistoryTarget.cs new file mode 100644 index 0000000000000..87abb6a10e1d3 --- /dev/null +++ b/ArchiSteamFarm/HistoryTarget.cs @@ -0,0 +1,80 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// +// Copyright 2015-2018 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using NLog; +using NLog.Targets; + +namespace ArchiSteamFarm { + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] + [Target(TargetName)] + internal sealed class HistoryTarget : TargetWithLayout { + internal const string TargetName = "History"; + + private const byte DefaultMaxCount = 10; + + internal IEnumerable ArchivedMessages => HistoryQueue; + + private readonly FixedSizeConcurrentQueue HistoryQueue = new FixedSizeConcurrentQueue(DefaultMaxCount); + + // This is NLog config property, it must have public get() and set() capabilities + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public byte MaxCount { + get => HistoryQueue.MaxCount; + set { + if (value == 0) { + ASF.ArchiLogger.LogNullError(nameof(value)); + return; + } + + HistoryQueue.MaxCount = value; + } + } + + // This constructor is intentionally public, as NLog uses it for creating targets + // It must stay like this as we want to have our targets defined in our NLog.config + public HistoryTarget(string name = null) => Name = name; + + protected override void Write(LogEventInfo logEvent) { + if (logEvent == null) { + ASF.ArchiLogger.LogNullError(nameof(logEvent)); + return; + } + + base.Write(logEvent); + + string message = Layout.Render(logEvent); + + HistoryQueue.Enqueue(message); + NewHistoryEntry?.Invoke(this, new NewHistoryEntryArgs(message)); + } + + internal event EventHandler NewHistoryEntry; + + internal sealed class NewHistoryEntryArgs : EventArgs { + internal readonly string Message; + + internal NewHistoryEntryArgs(string message) => Message = message ?? throw new ArgumentNullException(nameof(message)); + } + } +} \ No newline at end of file diff --git a/ArchiSteamFarm/IPC.cs b/ArchiSteamFarm/IPC.cs index 0f9ec54048de6..f352cc0bb1058 100644 --- a/ArchiSteamFarm/IPC.cs +++ b/ArchiSteamFarm/IPC.cs @@ -39,6 +39,8 @@ namespace ArchiSteamFarm { internal static class IPC { internal static bool IsRunning => IsHandlingRequests || IsListening; + private static readonly ConcurrentHashSet ActiveLogWebSockets = new ConcurrentHashSet(); + private static readonly HashSet CompressableContentTypes = new HashSet { "application/javascript", "text/css", @@ -69,9 +71,25 @@ internal static class IPC { } } + private static HistoryTarget HistoryTarget; private static HttpListener HttpListener; private static bool IsHandlingRequests; + internal static void OnNewHistoryTarget(HistoryTarget historyTarget) { + if (historyTarget == null) { + ASF.ArchiLogger.LogNullError(nameof(historyTarget)); + return; + } + + if (HistoryTarget != null) { + HistoryTarget.NewHistoryEntry -= OnNewHistoryEntry; + HistoryTarget = null; + } + + historyTarget.NewHistoryEntry += OnNewHistoryEntry; + HistoryTarget = historyTarget; + } + internal static void Start(string host, ushort port) { if (string.IsNullOrEmpty(host) || (port == 0)) { ASF.ArchiLogger.LogNullError(nameof(host) + " || " + nameof(port)); @@ -106,6 +124,7 @@ internal static class IPC { return; } + Logging.InitHistoryLogger(); Utilities.StartBackgroundFunction(HandleRequests); ASF.ArchiLogger.LogGenericInfo(Strings.IPCReady); } @@ -416,17 +435,34 @@ internal static class IPC { try { HttpListenerWebSocketContext webSocketContext = await context.AcceptWebSocketAsync(null).ConfigureAwait(false); - while (webSocketContext.WebSocket.State == WebSocketState.Open) { - const string testMessage = "WS works! In future there will be ASF log in JSON here"; - await webSocketContext.WebSocket.SendAsync(new ArraySegment(Encoding.UTF8.GetBytes(testMessage)), WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false); - await Task.Delay(3000).ConfigureAwait(false); + ActiveLogWebSockets.Add(webSocketContext.WebSocket); + + try { + // Push initial history if available + if (HistoryTarget != null) { + await Task.WhenAll(HistoryTarget.ArchivedMessages.Select(archivedMessage => PostLogUpdate(webSocketContext.WebSocket, archivedMessage))).ConfigureAwait(false); + } + + while (webSocketContext.WebSocket.State == WebSocketState.Open) { + WebSocketReceiveResult result = await webSocketContext.WebSocket.ReceiveAsync(new byte[0], CancellationToken.None).ConfigureAwait(false); + + if (result.MessageType != WebSocketMessageType.Close) { + await webSocketContext.WebSocket.CloseAsync(WebSocketCloseStatus.InvalidMessageType, "You're not supposed to be sending any message but Close!", CancellationToken.None); + break; + } + + await webSocketContext.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None).ConfigureAwait(false); + break; + } + } finally { + ActiveLogWebSockets.Remove(webSocketContext.WebSocket); } - } catch (WebSocketException) { - // Ignored, request is no longer valid + + return true; + } catch (WebSocketException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); return true; } - - return true; } private static async Task HandleApiStructure(HttpListenerRequest request, HttpListenerResponse response, string[] arguments, byte argumentsIndex) { @@ -641,6 +677,33 @@ internal static class IPC { } } + private static async void OnNewHistoryEntry(object sender, HistoryTarget.NewHistoryEntryArgs newHistoryEntryArgs) { + if ((sender == null) || (newHistoryEntryArgs == null)) { + ASF.ArchiLogger.LogNullError(nameof(sender) + " || " + nameof(newHistoryEntryArgs)); + return; + } + + await Task.WhenAll(ActiveLogWebSockets.Where(webSocket => webSocket.State == WebSocketState.Open).Select(webSocket => PostLogUpdate(webSocket, newHistoryEntryArgs.Message))).ConfigureAwait(false); + } + + private static async Task PostLogUpdate(WebSocket webSocket, string message) { + if ((webSocket == null) || string.IsNullOrEmpty(message)) { + ASF.ArchiLogger.LogNullError(nameof(webSocket) + " || " + nameof(message)); + return; + } + + if (webSocket.State != WebSocketState.Open) { + return; + } + + try { + string response = JsonConvert.SerializeObject(new GenericResponse(true, "OK", message)); + await webSocket.SendAsync(Encoding.UTF8.GetBytes(response), WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false); + } catch (WebSocketException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); + } + } + private static async Task ResponseBase(HttpListenerRequest request, HttpListenerResponse response, byte[] content, HttpStatusCode statusCode = HttpStatusCode.OK) { if ((request == null) || (response == null) || (content == null) || (content.Length == 0)) { ASF.ArchiLogger.LogNullError(nameof(request) + " || " + nameof(response) + " || " + nameof(content)); @@ -682,8 +745,8 @@ internal static class IPC { response.ContentLength64 = content.Length; await response.OutputStream.WriteAsync(content, 0, content.Length).ConfigureAwait(false); - } catch (ObjectDisposedException) { - // Ignored, request is no longer valid + } catch (ObjectDisposedException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); } } @@ -700,8 +763,8 @@ internal static class IPC { await ResponseBase(request, response, content).ConfigureAwait(false); } catch (FileNotFoundException) { await ResponseStatusCode(request, response, HttpStatusCode.NotFound).ConfigureAwait(false); - } catch (ObjectDisposedException) { - // Ignored, request is no longer valid + } catch (ObjectDisposedException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); } catch (Exception e) { ASF.ArchiLogger.LogGenericException(e); await ResponseStatusCode(request, response, HttpStatusCode.ServiceUnavailable).ConfigureAwait(false); @@ -751,8 +814,8 @@ internal static class IPC { byte[] content = response.ContentEncoding.GetBytes(text + Environment.NewLine); await ResponseBase(request, response, content, statusCode).ConfigureAwait(false); - } catch (ObjectDisposedException) { - // Ignored, request is no longer valid + } catch (ObjectDisposedException e) { + ASF.ArchiLogger.LogGenericDebuggingException(e); } } diff --git a/ArchiSteamFarm/Logging.cs b/ArchiSteamFarm/Logging.cs index a51ed8f4cd46c..5b8f161169cf2 100644 --- a/ArchiSteamFarm/Logging.cs +++ b/ArchiSteamFarm/Logging.cs @@ -50,7 +50,7 @@ internal static class Logging { } } - internal static void InitLoggers() { + internal static void InitCoreLoggers() { if (LogManager.Configuration != null) { IsUsingCustomConfiguration = true; InitConsoleLoggers(); @@ -81,6 +81,21 @@ internal static class Logging { InitConsoleLoggers(); } + internal static void InitHistoryLogger() { + if (IsUsingCustomConfiguration || (LogManager.Configuration == null)) { + return; + } + + // TODO: We could use some nice HTML layout for this + HistoryTarget historyTarget = new HistoryTarget("History") { Layout = GeneralLayout }; + + LogManager.Configuration.AddTarget(historyTarget); + LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, historyTarget)); + + LogManager.ReconfigExistingLoggers(); + IPC.OnNewHistoryTarget(historyTarget); + } + internal static void OnUserInputEnd() { IsWaitingForUserInput = false; @@ -127,6 +142,11 @@ internal static class Logging { if (IsWaitingForUserInput) { OnUserInputStart(); } + + HistoryTarget historyTarget = (HistoryTarget) LogManager.Configuration.AllTargets.FirstOrDefault(target => target is HistoryTarget); + if (historyTarget != null) { + IPC.OnNewHistoryTarget(historyTarget); + } } } } \ No newline at end of file diff --git a/ArchiSteamFarm/Program.cs b/ArchiSteamFarm/Program.cs index 6637918c0f535..67d1b0ef48c21 100644 --- a/ArchiSteamFarm/Program.cs +++ b/ArchiSteamFarm/Program.cs @@ -181,7 +181,8 @@ internal static class Program { AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; - // We must register our logging target as soon as possible + // We must register our logging targets as soon as possible + Target.Register(HistoryTarget.TargetName); Target.Register(SteamTarget.TargetName); InitCore(args); @@ -251,7 +252,7 @@ internal static class Program { ParsePreInitArgs(args); } - Logging.InitLoggers(); + Logging.InitCoreLoggers(); } private static async Task InitGlobalConfigAndLanguage() { diff --git a/ArchiSteamFarm/SteamTarget.cs b/ArchiSteamFarm/SteamTarget.cs index 92378377a2be3..9d26e610a4b58 100644 --- a/ArchiSteamFarm/SteamTarget.cs +++ b/ArchiSteamFarm/SteamTarget.cs @@ -43,9 +43,12 @@ internal sealed class SteamTarget : TargetWithLayout { public ulong SteamID { get; set; } // This constructor is intentionally public, as NLog uses it for creating targets - // It must stay like this as we want to have SteamTargets defined in our NLog.config + // It must stay like this as we want to have our targets defined in our NLog.config // Keeping date in default layout also doesn't make much sense, so we remove it by default - public SteamTarget() => Layout = "${level:uppercase=true}|${logger}|${message}"; + public SteamTarget(string name = null) { + Name = name; + Layout = "${level:uppercase=true}|${logger}|${message}"; + } protected override async void Write(LogEventInfo logEvent) { if (logEvent == null) {