├── Art ├── menu.jpg ├── notepad.jpg └── BulbIcon.ico ├── DevComrade ├── Art │ └── BulbIcon.ico ├── DevComrade.csproj ├── Program.cs └── App.config ├── global.json ├── Package ├── make-and-run.bat ├── publish.ps1 └── make.bat ├── AppLogic ├── Models │ ├── HotkeyCollection.cs │ ├── OptionCollection.cs │ └── Hotkey.cs ├── Presenter │ ├── HotkeyHandlerHost.Designer.cs │ ├── IScriptGlobals.cs │ ├── IHotkeyHandlerProvider.cs │ ├── Events.cs │ ├── ScriptGlobals.cs │ ├── HotkeyHandler.cs │ ├── IHotkeyHandlerHost.cs │ ├── ClipboardFormatMonitor.cs │ ├── ScriptHotkeyHandlers.cs │ ├── PredefinedHotkeyHandlers.cs │ └── Notepad.cs ├── Events │ ├── IEventTargetProp.cs │ ├── IEventTargetHub.cs │ ├── EventTarget.cs │ └── EventTargetHubExtensions.cs ├── User.config ├── AppLogic.csproj ├── Helpers │ ├── ReflectionExtensions.cs │ ├── Disposable.cs │ ├── ClipboardAccess.cs │ ├── SubscriptionScope.cs │ ├── StringPrimitiveExtensions.cs │ ├── ClipboardFormats.cs │ ├── WaitCursorScope.cs │ ├── AttachedThreadInputScope.cs │ ├── ExceptionExtensions.cs │ ├── InputUtils.cs │ ├── StringExtensions.cs │ ├── KeyboardInput.cs │ ├── TaskExtensions.cs │ ├── Diagnostics.cs │ └── WinUtils.cs ├── Config │ ├── OptionConfigSection.cs │ ├── ConfigurationExtensions.cs │ ├── HotkeyConfigSection.cs │ ├── ParsingHelpers.cs │ └── Configuration.cs └── Program.cs ├── Tests ├── Tests.csproj ├── IAsyncApartment.cs ├── InputHelpersTest.cs ├── CategoryTraceListener.cs ├── SingleThreadApartmentTest.cs ├── ThreadPoolApartmentTest.cs ├── WinFormsApartmentTest.cs ├── AsyncCoroutineProxy.cs ├── WinFormsApartment.cs ├── ThreadPoolApartment.cs ├── AsyncCoroutineProxyTest.cs ├── KeyboardInputTest.cs ├── SingleThreadApartment.cs └── AsyncApartmentBase.cs ├── .vscode ├── launch.json └── tasks.json ├── DevComrade.sln ├── .gitignore ├── README.md └── LICENSE /Art/menu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postprintum/devcomrade/HEAD/Art/menu.jpg -------------------------------------------------------------------------------- /Art/notepad.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postprintum/devcomrade/HEAD/Art/notepad.jpg -------------------------------------------------------------------------------- /Art/BulbIcon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postprintum/devcomrade/HEAD/Art/BulbIcon.ico -------------------------------------------------------------------------------- /DevComrade/Art/BulbIcon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postprintum/devcomrade/HEAD/DevComrade/Art/BulbIcon.ico -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "6.0.100", 4 | "rollForward": "latestMinor" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Package/make-and-run.bat: -------------------------------------------------------------------------------- 1 | @pushd %~dp0 2 | @call make.bat 3 | @if errorlevel 1 goto :error 4 | 5 | start ..\DevComrade\bin\Release\net6.0-windows\win10-x64\DevComrade.exe 6 | @goto :success 7 | 8 | :error 9 | @echo Error exit code: %errorlevel% 10 | @popd 11 | @exit /b 1 12 | 13 | :success 14 | @popd 15 | @exit /b 1 16 | -------------------------------------------------------------------------------- /Package/publish.ps1: -------------------------------------------------------------------------------- 1 | #Requires -Version 6.0 2 | Set-StrictMode -Version Latest 3 | $ErrorActionPreference = 'Stop' 4 | 5 | Set-Location $PSScriptRoot 6 | 7 | #TODO: make a Chocolatey package 8 | 9 | dotnet clean 10 | dotnet publish -r win10-x64 -c Release --self-contained true -p:PublishTrimmed=true ..\DevComrade 11 | -------------------------------------------------------------------------------- /Package/make.bat: -------------------------------------------------------------------------------- 1 | @pushd %~dp0 2 | 3 | dotnet publish -r win10-x64 -c Release --self-contained true -p:PublishTrimmed=false ..\DevComrade 4 | @if errorlevel 1 goto :error 5 | @goto :success 6 | 7 | :error 8 | @echo Error exit code: %errorlevel% 9 | @popd 10 | @exit /b 1 11 | 12 | :success 13 | @popd 14 | @echo Run DevComrade from: 15 | @where /r "%~dp0.." "DevComrade.exe" 16 | @exit /b 0 17 | -------------------------------------------------------------------------------- /AppLogic/Models/HotkeyCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System.Collections.Generic; 9 | 10 | namespace AppLogic.Models 11 | { 12 | internal class HotkeyCollection : List 13 | { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /AppLogic/Models/OptionCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System.Collections.Generic; 9 | 10 | namespace AppLogic.Models 11 | { 12 | internal class OptionCollection : Dictionary 13 | { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /AppLogic/Presenter/HotkeyHandlerHost.Designer.cs: -------------------------------------------------------------------------------- 1 | namespace AppLogic.Presenter 2 | { 3 | partial class HotkeyHandlerHost 4 | { 5 | /// 6 | /// Required designer variable. 7 | /// 8 | 9 | #region Component Designer generated code 10 | 11 | /// 12 | /// Required method for Designer support - do not modify 13 | /// the contents of this method with the code editor. 14 | /// 15 | private void InitializeComponent() 16 | { 17 | } 18 | 19 | #endregion 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /AppLogic/Presenter/IScriptGlobals.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System.Threading; 9 | 10 | namespace AppLogic.Presenter 11 | { 12 | public interface IScriptGlobals 13 | { 14 | CancellationToken Token { get; } 15 | IHotkeyHandlerHost Host { get; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /AppLogic/Presenter/IHotkeyHandlerProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Models; 9 | using System.Diagnostics.CodeAnalysis; 10 | 11 | namespace AppLogic.Presenter 12 | { 13 | public interface IHotkeyHandlerProvider 14 | { 15 | bool CanHandle(Hotkey hotkey, [NotNullWhen(true)] out HotkeyHandlerCallback? callback); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /AppLogic/Events/IEventTargetProp.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020+ by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | 14 | namespace AppLogic.Events 15 | { 16 | public interface IEventTargetProp where T : EventArgs 17 | { 18 | public EventTarget Value { get; init; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AppLogic/Presenter/Events.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020+ by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | 13 | namespace AppLogic.Events 14 | { 15 | internal class ClipboardUpdateEventArgs : EventArgs { }; 16 | 17 | internal class ControlClipboardMonitoringEventArgs : EventArgs 18 | { 19 | public bool Enable { get; set; } 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /AppLogic/Events/IEventTargetHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020+ by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | 14 | namespace AppLogic.Events 15 | { 16 | public interface IEventTargetHub 17 | { 18 | public EventTarget? GetEventTarget() 19 | where T : EventArgs => 20 | (this as IEventTargetProp)?.Value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AppLogic/User.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /DevComrade/DevComrade.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | net6.0-windows 6 | true 7 | DevComrade.Program 8 | Art\BulbIcon.ico 9 | en 10 | 11 | 1.0.0.0 12 | 1.0.0 13 | 14 | Postprintum Pty Ltd 15 | DevComrade 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /AppLogic/Presenter/ScriptGlobals.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System.Threading; 9 | 10 | namespace AppLogic.Presenter 11 | { 12 | public class ScriptGlobals : IScriptGlobals 13 | { 14 | public IHotkeyHandlerHost Host { get; } 15 | public CancellationToken Token { get; } 16 | 17 | public ScriptGlobals(IHotkeyHandlerHost host, CancellationToken token) 18 | { 19 | this.Host = host; 20 | this.Token = token; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DevComrade/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Reflection; 10 | using System.Runtime.CompilerServices; 11 | 12 | [assembly: InternalsVisibleTo("Tests")] 13 | [assembly: AssemblyCopyright("Copyright (c) 2020 Postprintum Pty Ltd")] 14 | 15 | namespace DevComrade 16 | { 17 | public static class Program 18 | { 19 | [STAThread] 20 | public static void Main(string[] args) 21 | { 22 | AppLogic.Program.Main(args); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /AppLogic/AppLogic.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0-windows 5 | true 6 | en 7 | 1.0.0.0 8 | 1.0.0 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /AppLogic/Events/EventTarget.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020+ by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | 14 | namespace AppLogic.Events 15 | { 16 | /// 17 | /// A simple event container for pub/sup scenarios, resembling JavaScript's EventTarget 18 | /// 19 | public class EventTarget where T : EventArgs 20 | { 21 | public event EventHandler? Event; 22 | 23 | public void Dispatch(object? source, T eventArgs) => 24 | this.Event?.Invoke(source, eventArgs); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /AppLogic/Presenter/HotkeyHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Models; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace AppLogic.Presenter 13 | { 14 | public delegate Task HotkeyHandlerCallback(Hotkey hotkey, CancellationToken token); 15 | 16 | public class HotkeyHandler 17 | { 18 | public Hotkey Hotkey { get; } 19 | public HotkeyHandlerCallback Callback { get; } 20 | 21 | public HotkeyHandler(Hotkey hotkey, HotkeyHandlerCallback callback) 22 | { 23 | Hotkey = hotkey; 24 | Callback = callback; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /AppLogic/Helpers/ReflectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Reflection; 12 | using System.Runtime.CompilerServices; 13 | using System.Threading; 14 | using System.Threading.Tasks; 15 | 16 | namespace AppLogic.Helpers 17 | { 18 | /// 19 | /// Reflection extensions 20 | /// 21 | internal static partial class ReflectionExtensions 22 | { 23 | public static T CreateDelegate(this MethodInfo @this, object? target) where T : Delegate => 24 | (T)@this.CreateDelegate(typeof(T), target); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0-windows 5 | true 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /AppLogic/Presenter/IHotkeyHandlerHost.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace AppLogic.Presenter 12 | { 13 | /// 14 | /// Host interface for hotkey handlers 15 | /// 16 | /// 17 | public interface IHotkeyHandlerHost 18 | { 19 | int TabSize { get; } 20 | bool ClipboardContainsText(); 21 | string GetClipboardText(); 22 | void ClearClipboard(); 23 | void SetClipboardText(string text); 24 | void SetClipboardDataObject(object data); 25 | Task FeedTextAsync(string text, CancellationToken token); 26 | void PlayNotificationSound(); 27 | void ShowMenu(); 28 | Task ShowNotepad(string? text); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /AppLogic/Models/Hotkey.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | 10 | namespace AppLogic.Models 11 | { 12 | public class Hotkey 13 | { 14 | public string Name { get; set; } = String.Empty; 15 | public string? MenuItem { get; set; } 16 | public uint? Mods { get; set; } 17 | public uint? Vkey { get; set; } 18 | public bool IsScript { get; set; } 19 | public bool AddSeparator { get; set; } 20 | public string? Data { get; set; } 21 | 22 | public bool HasHotkey => Vkey.HasValue && Mods.HasValue; 23 | 24 | public override bool Equals(object? obj) 25 | { 26 | return (obj is Hotkey other) && Name.Equals(other.Name); 27 | } 28 | public override int GetHashCode() 29 | { 30 | return Name.GetHashCode(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/DevComrade/bin/Debug/net5.0-windows/DevComrade.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/DevComrade", 16 | // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console 17 | "console": "internalConsole", 18 | "stopAtEntry": false 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /AppLogic/Helpers/Disposable.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Threading.Tasks; 10 | 11 | namespace AppLogic.Helpers 12 | { 13 | /// 14 | /// Disposable 15 | /// 16 | internal struct Disposable : IDisposable 17 | { 18 | private readonly Action _dispose; 19 | 20 | private Disposable(Action dispose) 21 | { 22 | _dispose = dispose; 23 | } 24 | 25 | void IDisposable.Dispose() 26 | { 27 | _dispose(); 28 | } 29 | 30 | public static async ValueTask CreateAsync(Func func, Action dispose) 31 | { 32 | await func(); 33 | return new Disposable(dispose); 34 | } 35 | 36 | public static IDisposable Create(Action action, Action dispose) 37 | { 38 | action(); 39 | return new Disposable(dispose); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /AppLogic/Helpers/ClipboardAccess.cs: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | 3 | using System; 4 | using System.Runtime.CompilerServices; 5 | using System.Runtime.InteropServices; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace AppLogic.Helpers 10 | { 11 | internal class ClipboardAccess 12 | { 13 | public static bool IsClipboardError(Exception ex) 14 | { 15 | return ex is ExternalException && ex.HResult == WinApi.CLIPBRD_E_CANT_OPEN; 16 | } 17 | 18 | /// 19 | /// Querying or setting clipboard data can fail, as other applications can 20 | /// be locking the clipboard. 21 | /// Polling with OpenClipboard/CloseClipboard helps solving this. 22 | /// It's OK to pass IntPtr.Zero for hwnd 23 | /// 24 | public static async Task EnsureAsync( 25 | IntPtr hwnd, 26 | int interval, 27 | CancellationToken token) 28 | { 29 | while (!WinApi.OpenClipboard(hwnd)) 30 | { 31 | await Task.Delay(interval, token); 32 | } 33 | WinApi.CloseClipboard(); 34 | token.ThrowIfCancellationRequested(); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/IAsyncApartment.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace Tests 13 | { 14 | public interface IAsyncApartment: IAsyncDisposable 15 | { 16 | /// A wrapper around Task.Factory.StartNew to run an action 17 | public Task Run(Action action, CancellationToken token = default); 18 | 19 | /// A wrapper around Task.Factory.StartNew to run a func 20 | public Task Run(Func func, CancellationToken token = default); 21 | 22 | /// A wrapper around Task.Factory.StartNew to run an async func 23 | public Task Run(Func func, CancellationToken token = default); 24 | 25 | /// A wrapper around Task.Factory.StartNew to run an async func 26 | public Task Run(Func> func, CancellationToken token = default); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/InputHelpersTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using Microsoft.VisualStudio.TestTools.UnitTesting; 10 | using System.Diagnostics; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using System.Windows.Forms; 14 | 15 | namespace Tests 16 | { 17 | [TestClass] 18 | public class InputHelpersTest 19 | { 20 | [TestMethod] 21 | public async Task Test_TimerYield_in_WinFormsApartment() 22 | { 23 | await using var apartment = new WinFormsApartment(); 24 | var sw = new Stopwatch(); 25 | 26 | await apartment.Run(async () => 27 | { 28 | Assert.IsTrue(SynchronizationContext.Current is WindowsFormsSynchronizationContext); 29 | 30 | sw.Start(); 31 | await InputUtils.TimerYield(1000); 32 | sw.Stop(); 33 | }); 34 | 35 | var lapse = sw.ElapsedMilliseconds; 36 | Assert.IsTrue(lapse > 900 && lapse < 1100); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /AppLogic/Events/EventTargetHubExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020+ by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | 14 | namespace AppLogic.Events 15 | { 16 | public static class EventTargetHubExtensions 17 | { 18 | public static bool HasEventTarget(this IEventTargetHub @this) 19 | where T : EventArgs => 20 | @this.GetEventTarget() != null; 21 | 22 | public static void AddListener(this IEventTargetHub @this, EventHandler listener) 23 | where T : EventArgs => 24 | @this.GetEventTarget()!.Event += listener; 25 | 26 | public static void RemoveListener(this IEventTargetHub @this, EventHandler listener) 27 | where T : EventArgs => 28 | @this.GetEventTarget()!.Event -= listener; 29 | 30 | public static void Dispatch(this IEventTargetHub @this, object? source, T eventArgs) 31 | where T : EventArgs => 32 | @this.GetEventTarget()?.Dispatch(source, eventArgs); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/DevComrade/DevComrade.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/DevComrade/DevComrade.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/DevComrade/DevComrade.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /AppLogic/Config/OptionConfigSection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using AppLogic.Models; 10 | using System; 11 | using System.Configuration; 12 | using System.Linq; 13 | using System.Xml; 14 | 15 | namespace AppLogic.Config 16 | { 17 | internal class OptionConfigSection : IConfigurationSectionHandler 18 | { 19 | public object Create(object parent, object context, XmlNode section) 20 | { 21 | var result = new OptionCollection(); 22 | 23 | foreach (var node in section.ChildNodes 24 | .Cast() 25 | .Where(child => child.Name == "option")) 26 | { 27 | void throwFormatException() => throw new FormatException(node.OuterXml); 28 | 29 | var name = node!.Attributes!["name"]?.Value; 30 | if (name.IsNullOrWhiteSpace()) 31 | { 32 | throwFormatException(); 33 | } 34 | 35 | var value = node.Attributes["value"]?.Value ?? String.Empty; 36 | result.Add(name!, value); 37 | } 38 | 39 | return result; 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /AppLogic/Helpers/SubscriptionScope.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Runtime.CompilerServices; 10 | 11 | namespace AppLogic.Helpers 12 | { 13 | /// 14 | /// Handle an event with a scope, e.g.: 15 | /// 16 | /// 17 | /// 18 | /// using (new SubscriptionScope( 19 | /// (s, e) => cts.Cancel(), 20 | /// listener => form.FormClosed += listener, 21 | /// listener => form.FormClosed -= listener)) 22 | /// { 23 | /// ... 24 | /// } 25 | /// 26 | /// 27 | internal struct SubscriptionScope: IDisposable 28 | where TListener : Delegate 29 | { 30 | readonly TListener _listener; 31 | readonly Action _unsubscribe; 32 | 33 | private SubscriptionScope( 34 | TListener listener, 35 | Action subscribe, 36 | Action unsubscribe) 37 | { 38 | _listener = listener; 39 | _unsubscribe = unsubscribe; 40 | subscribe(listener); 41 | } 42 | 43 | void IDisposable.Dispose() 44 | { 45 | _unsubscribe(_listener); 46 | } 47 | 48 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 49 | public static IDisposable Create( 50 | TListener listener, 51 | Action subscribe, 52 | Action unsubscribe) 53 | { 54 | return new SubscriptionScope(listener, subscribe, unsubscribe); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Tests/CategoryTraceListener.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Diagnostics; 11 | 12 | namespace Tests 13 | { 14 | public class CategoryTraceListener : TraceListener 15 | { 16 | private readonly List _list = new List(); 17 | private readonly object _lock = new object(); 18 | private readonly string _categoty; 19 | 20 | public CategoryTraceListener(string categoty) 21 | { 22 | _categoty = categoty; 23 | } 24 | 25 | public override void Write(string? message) 26 | { 27 | } 28 | 29 | public override void WriteLine(string? message) 30 | { 31 | } 32 | 33 | public override bool IsThreadSafe => true; 34 | 35 | public override void WriteLine(string? message, string? category) 36 | { 37 | lock (_lock) 38 | { 39 | if (String.CompareOrdinal(category, _categoty) == 0) 40 | { 41 | _list.Add(message ?? String.Empty); 42 | } 43 | } 44 | } 45 | 46 | public override void Write(string? message, string? category) 47 | { 48 | WriteLine(message, category); 49 | } 50 | 51 | public string[] ToArray() 52 | { 53 | lock (_lock) 54 | { 55 | return _list.ToArray(); 56 | } 57 | } 58 | 59 | public void Clear() 60 | { 61 | lock (_lock) 62 | { 63 | _list.Clear(); 64 | } 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /AppLogic/Config/ConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using System; 10 | using System.Configuration; 11 | using System.Xml; 12 | 13 | namespace AppLogic.Config 14 | { 15 | internal static class ConfigurationExtensions 16 | { 17 | /// 18 | /// Get custom object for a section listed in 19 | /// 20 | public static object? GetCustomSection(this System.Configuration.Configuration @this, string name) 21 | { 22 | var info = @this.GetSection(name)?.SectionInformation; 23 | if (info == null) 24 | { 25 | return null; 26 | } 27 | 28 | var type = Type.GetType(info.Type); 29 | if (type == null) 30 | { 31 | return null; 32 | } 33 | 34 | var xml = info.GetRawXml(); 35 | if (xml.IsNullOrWhiteSpace()) 36 | { 37 | return null; 38 | } 39 | 40 | var xmlDocument = new XmlDocument(); 41 | xmlDocument.LoadXml(xml); 42 | 43 | var sectionHandler = Activator.CreateInstance(type) as IConfigurationSectionHandler; 44 | if (sectionHandler == null) 45 | { 46 | return null; 47 | } 48 | 49 | return sectionHandler.Create(null, null, xmlDocument.DocumentElement); 50 | } 51 | 52 | public static T? GetCustomSection(this System.Configuration.Configuration @this, string name) 53 | where T: class, new() 54 | { 55 | return @this.GetCustomSection(name) as T ?? new T(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DevComrade.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30204.135 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevComrade", "DevComrade\DevComrade.csproj", "{A6209C54-D95A-4605-960F-1CC94BDC3599}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppLogic", "AppLogic\AppLogic.csproj", "{F2BC7416-696D-445C-A55D-ABC88C397839}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{200C7D25-E77C-4249-B17E-467F2216D556}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {A6209C54-D95A-4605-960F-1CC94BDC3599}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {A6209C54-D95A-4605-960F-1CC94BDC3599}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {A6209C54-D95A-4605-960F-1CC94BDC3599}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {A6209C54-D95A-4605-960F-1CC94BDC3599}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {F2BC7416-696D-445C-A55D-ABC88C397839}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {F2BC7416-696D-445C-A55D-ABC88C397839}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {F2BC7416-696D-445C-A55D-ABC88C397839}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {F2BC7416-696D-445C-A55D-ABC88C397839}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {200C7D25-E77C-4249-B17E-467F2216D556}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {200C7D25-E77C-4249-B17E-467F2216D556}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {200C7D25-E77C-4249-B17E-467F2216D556}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {200C7D25-E77C-4249-B17E-467F2216D556}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {A126A440-E94A-4AEE-A6C9-E113B4D3817D} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /AppLogic/Presenter/ClipboardFormatMonitor.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020+ by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using System; 10 | using System.Runtime.InteropServices; 11 | using System.Threading.Tasks; 12 | using System.Windows.Forms; 13 | using AppLogic.Events; 14 | 15 | namespace AppLogic.Presenter 16 | { 17 | internal class ClipboardFormatMonitor : NativeWindow 18 | { 19 | private IEventTargetHub EventTargetHub { get; init; } 20 | 21 | public ClipboardFormatMonitor(IEventTargetHub hub) 22 | { 23 | this.EventTargetHub = hub; 24 | 25 | var cp = new CreateParams() 26 | { 27 | Caption = String.Empty, 28 | Style = unchecked((int)WinApi.WS_POPUP), 29 | Parent = WinApi.HWND_MESSAGE, 30 | }; 31 | 32 | base.CreateHandle(cp); 33 | } 34 | 35 | public async Task StartAsync() 36 | { 37 | // AddClipboardFormatListener may fail when 38 | // another app clipboard operation is in progress 39 | 40 | const int retryDelay = 500; 41 | int retryAttempts = 10; 42 | 43 | while (true) 44 | { 45 | if (WinApi.AddClipboardFormatListener(this.Handle)) 46 | { 47 | return; 48 | } 49 | if (--retryAttempts <= 0) 50 | { 51 | break; 52 | } 53 | await Task.Delay(retryDelay); 54 | } 55 | 56 | throw WinUtils.CreateExceptionFromLastWin32Error(); 57 | } 58 | 59 | protected override void WndProc(ref Message m) 60 | { 61 | if (m.Msg == WinApi.WM_CLIPBOARDUPDATE) 62 | { 63 | this.EventTargetHub.Dispatch(this, new ClipboardUpdateEventArgs()); 64 | } 65 | base.WndProc(ref m); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /AppLogic/Helpers/StringPrimitiveExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Diagnostics.CodeAnalysis; 10 | using System.Runtime.CompilerServices; 11 | using System.Text.RegularExpressions; 12 | 13 | namespace AppLogic.Helpers 14 | { 15 | /// 16 | /// Primitive String extension 17 | /// TODO: unit tests 18 | /// 19 | internal static class StringPrimitiveExtensions 20 | { 21 | /// 22 | /// Using IsNotNullNorEmpty, especially as an extension method, may be an unpopular opinion, 23 | /// yet the typical !String.IsNullOrEmpty(s) has often been error-prone to me. 24 | /// Now, this is a personal project and I am not bound by corporate coding standards :) 25 | /// Using #nullable enable and [NotNullWhen] helps, as well. 26 | /// 27 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 28 | public static bool IsNotNullNorEmpty([NotNullWhen(true)] this string? @this) => 29 | !String.IsNullOrEmpty(@this); 30 | 31 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 32 | public static bool IsNullOrEmpty([NotNullWhen(false)] this string? @this) => 33 | String.IsNullOrEmpty(@this); 34 | 35 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 36 | public static bool IsNotNullNorWhiteSpace([NotNullWhen(true)] this string? @this) => 37 | !String.IsNullOrWhiteSpace(@this); 38 | 39 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 40 | public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? @this) => 41 | String.IsNullOrWhiteSpace(@this); 42 | 43 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 44 | public static bool IsEmpty(this string @this) => 45 | @this.Length == 0; 46 | 47 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 48 | public static bool IsNotEmpty(this string @this) => 49 | (uint)@this.Length > 0u; 50 | 51 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 52 | public static string Replace(this string @this, Regex regex, string replacement) => 53 | regex.Replace(@this, replacement); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /AppLogic/Helpers/ClipboardFormats.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Windows.Forms; 7 | 8 | namespace AppLogic.Helpers 9 | { 10 | internal static class ClipboardFormats 11 | { 12 | static readonly string HEADER = 13 | "Version:0.9\r\n" + 14 | "StartHTML:{0:0000000000}\r\n" + 15 | "EndHTML:{1:0000000000}\r\n" + 16 | "StartFragment:{2:0000000000}\r\n" + 17 | "EndFragment:{3:0000000000}\r\n" + 18 | "StartSelection:{4:0000000000}\r\n" + 19 | "EndSelection:{5:0000000000}\r\n"; 20 | 21 | static readonly string HTML_START = 22 | "\r\n" + 23 | "\r\n" + 24 | "\r\n" + 25 | "\r\n" + 26 | "\r\n" + 27 | ""; 28 | 29 | static readonly string HTML_END = 30 | "\r\n" + 31 | "\r\n" + 32 | "\r\n" + 33 | ""; 34 | 35 | public static string ConvertHtmlToClipboardData(string html) 36 | { 37 | var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); 38 | var data = Array.Empty(); 39 | 40 | var header = encoding.GetBytes(String.Format(HEADER, 0, 1, 2, 3, 4, 5)); 41 | data = data.Concat(header).ToArray(); 42 | 43 | var startHtml = data.Length; 44 | data = data.Concat(encoding.GetBytes(HTML_START)).ToArray(); 45 | 46 | var startFragment = data.Length; 47 | data = data.Concat(encoding.GetBytes(html)).ToArray(); 48 | 49 | var endFragment = data.Length; 50 | data = data.Concat(encoding.GetBytes(HTML_END)).ToArray(); 51 | 52 | var endHtml = data.Length; 53 | 54 | var newHeader = encoding.GetBytes( 55 | String.Format(HEADER, 56 | startHtml, endHtml, 57 | startFragment, endFragment, 58 | startFragment, endFragment)); 59 | 60 | if (newHeader.Length != startHtml) 61 | { 62 | throw new InvalidOperationException(nameof(ConvertHtmlToClipboardData)); 63 | } 64 | 65 | Array.Copy(newHeader, data, length: startHtml); 66 | 67 | return encoding.GetString(data); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /AppLogic/Helpers/WaitCursorScope.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Diagnostics; 10 | using System.Threading; 11 | using System.Windows.Forms; 12 | 13 | namespace AppLogic.Helpers 14 | { 15 | internal class WaitCursorScope : IDisposable 16 | { 17 | private static readonly ThreadLocal s_current = 18 | new ThreadLocal(); 19 | 20 | private const int DELAY = 200; 21 | 22 | private bool IsCurrent => s_current.Value == this; 23 | 24 | private readonly Func? _showWhen; 25 | private readonly Cursor? _oldCursor; 26 | private readonly System.Windows.Forms.Timer? _timer; 27 | private readonly Stopwatch _stopwatch = new Stopwatch(); 28 | 29 | private void OnIdle(object? s, EventArgs e) 30 | { 31 | if (this.IsCurrent && 32 | _showWhen?.Invoke() != false && // i.e., if null or true 33 | _stopwatch.ElapsedMilliseconds >= DELAY) 34 | { 35 | Cursor.Current = Cursors.AppStarting; 36 | } 37 | } 38 | 39 | private WaitCursorScope(Func? showWhen = null) 40 | { 41 | s_current.Value?.Dispose(); 42 | s_current.Value = this; 43 | 44 | _stopwatch.Start(); 45 | _oldCursor = Cursor.Current; 46 | _showWhen = showWhen; 47 | _timer = new System.Windows.Forms.Timer { Interval = 250 }; 48 | _timer.Tick += OnIdle; 49 | _timer.Start(); 50 | Application.Idle += OnIdle; 51 | } 52 | 53 | public void Stop() 54 | { 55 | if (s_current.Value == this) 56 | { 57 | s_current.Value = null; 58 | _timer?.Dispose(); 59 | Application.Idle -= OnIdle; 60 | Cursor.Hide(); 61 | Cursor.Current = _oldCursor ?? Cursors.Default; 62 | Cursor.Show(); 63 | } 64 | } 65 | 66 | void IDisposable.Dispose() 67 | { 68 | Stop(); 69 | } 70 | 71 | public static IDisposable Create(Func? showWhen = null) 72 | { 73 | return new WaitCursorScope(showWhen); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Tests/SingleThreadApartmentTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using Microsoft.VisualStudio.TestTools.UnitTesting; 10 | using System; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace Tests 15 | { 16 | [TestClass] 17 | public class SingleThreadApartmentTest 18 | { 19 | [TestMethod] 20 | public async Task Test_async_void_exceptions_are_captured() 21 | { 22 | await using var apartment = new SingleThreadApartment(); 23 | await apartment.Run(async () => 24 | { 25 | await Task.Yield(); 26 | 27 | Assert.IsTrue(SynchronizationContext.Current is SingleThreadApartment.SingleThreadSyncContext); 28 | 29 | async void AsyncVoidMethod0() 30 | { 31 | await Task.Delay(400); 32 | throw new InvalidOperationException(); 33 | } 34 | 35 | async void AsyncVoidMethod1() 36 | { 37 | await Task.Delay(600); 38 | throw new NotSupportedException(); 39 | } 40 | 41 | AsyncVoidMethod0(); 42 | AsyncVoidMethod1(); 43 | }); 44 | 45 | apartment.Complete(); 46 | 47 | try 48 | { 49 | await apartment.Completion.WithAggregatedExceptions(); 50 | Assert.Fail("Must not reach here, expecting exceptions."); 51 | } 52 | catch (AssertFailedException) 53 | { 54 | throw; 55 | } 56 | catch (Exception ex) 57 | { 58 | apartment.ClearExceptions(); 59 | 60 | // we expect to see 2 exceptions wrapped as AggregateException 61 | Assert.IsTrue(apartment.AnyBackgroundOperation == false); 62 | 63 | var aggregate = ex as AggregateException; 64 | Assert.IsNotNull(aggregate); 65 | Assert.IsTrue(aggregate!.InnerExceptions.Count == 2); 66 | Assert.IsTrue(aggregate.InnerExceptions[0] is InvalidOperationException); 67 | Assert.IsTrue(aggregate.InnerExceptions[1] is NotSupportedException); 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/ThreadPoolApartmentTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using Microsoft.VisualStudio.TestTools.UnitTesting; 10 | using System; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace Tests 15 | { 16 | [TestClass] 17 | public class ThreadPoolApartmentTest 18 | { 19 | [TestMethod] 20 | public async Task Test_async_void_exceptions_are_captured() 21 | { 22 | await using var apartment = new ThreadPoolApartment(); 23 | await apartment.Run(async () => 24 | { 25 | await Task.Yield(); 26 | 27 | Assert.IsTrue(SynchronizationContext.Current is ThreadPoolApartment.ThreadPoolSyncContext); 28 | 29 | async void AsyncVoidMethod0() 30 | { 31 | await Task.Delay(400); 32 | throw new InvalidOperationException(); 33 | } 34 | 35 | async void AsyncVoidMethod1() 36 | { 37 | await Task.Delay(600); 38 | throw new NotSupportedException(); 39 | } 40 | 41 | AsyncVoidMethod0(); 42 | AsyncVoidMethod1(); 43 | }); 44 | 45 | await Task.Delay(200); 46 | Assert.IsTrue(apartment.AnyBackgroundOperation == true); 47 | apartment.Complete(); 48 | 49 | try 50 | { 51 | await apartment.Completion.WithAggregatedExceptions(); 52 | Assert.Fail("Must not reach here, expecting exceptions."); 53 | } 54 | catch (AssertFailedException) 55 | { 56 | throw; 57 | } 58 | catch (Exception ex) 59 | { 60 | apartment.ClearExceptions(); 61 | 62 | // we expect to see 2 exceptions wrapped as AggregateException 63 | Assert.IsTrue(apartment.AnyBackgroundOperation == false); 64 | 65 | var aggregate = ex as AggregateException; 66 | Assert.IsNotNull(aggregate); 67 | Assert.IsTrue(aggregate!.InnerExceptions.Count == 2); 68 | Assert.IsTrue(aggregate.InnerExceptions[0] is InvalidOperationException); 69 | Assert.IsTrue(aggregate.InnerExceptions[1] is NotSupportedException); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /DevComrade/App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 | 8 | 9 | 10 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Tests/WinFormsApartmentTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using Microsoft.VisualStudio.TestTools.UnitTesting; 10 | using System; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace Tests 15 | { 16 | [TestClass] 17 | public class WinFormsApartmentTest 18 | { 19 | [TestMethod] 20 | public async Task Test_async_void_exceptions_are_captured() 21 | { 22 | await using var apartment = new WinFormsApartment(); 23 | await apartment.Run(async () => 24 | { 25 | await Task.Yield(); 26 | 27 | Assert.IsTrue(SynchronizationContext.Current is 28 | System.Windows.Forms.WindowsFormsSynchronizationContext); 29 | 30 | async void AsyncVoidMethod1() 31 | { 32 | await Task.Delay(200); 33 | throw new InvalidOperationException(); 34 | } 35 | 36 | async void AsyncVoidMethod2() 37 | { 38 | await Task.Delay(400); 39 | throw new NotSupportedException(); 40 | } 41 | 42 | AsyncVoidMethod1(); 43 | AsyncVoidMethod2(); 44 | }); 45 | 46 | // we can't track background operations with WindowsFormsSynchronizationContext 47 | Assert.IsTrue(apartment.AnyBackgroundOperation == null); 48 | 49 | await Task.Delay(600); // race with both async void methods 50 | apartment.Complete(); 51 | 52 | try 53 | { 54 | await apartment.Completion.WithAggregatedExceptions(); 55 | Assert.Fail("Must not reach here, expecting exceptions."); 56 | } 57 | catch (AssertFailedException) 58 | { 59 | throw; 60 | } 61 | catch (Exception ex) 62 | { 63 | apartment.ClearExceptions(); 64 | 65 | // we expect to see 2 exceptions wrapped as AggregateException 66 | 67 | var aggregate = ex as AggregateException; 68 | Assert.IsNotNull(aggregate); 69 | Assert.IsTrue(aggregate!.InnerExceptions.Count == 2); 70 | Assert.IsTrue(aggregate.InnerExceptions[0] is InvalidOperationException); 71 | Assert.IsTrue(aggregate.InnerExceptions[1] is NotSupportedException); 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /AppLogic/Helpers/AttachedThreadInputScope.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Text; 10 | using System.Threading; 11 | 12 | namespace AppLogic.Helpers 13 | { 14 | /// 15 | /// Attach Win32 input queue to another thread 16 | /// 17 | internal class AttachedThreadInputScope: IDisposable 18 | { 19 | private static readonly ThreadLocal s_current = 20 | new ThreadLocal(); 21 | 22 | public bool IsCurrent => s_current.Value == this; 23 | public bool IsAttached => _attached; 24 | public IntPtr ForegroundWindow => _foregroundWindow; 25 | 26 | private bool _attached = false; 27 | private readonly IntPtr _foregroundWindow = IntPtr.Zero; 28 | private readonly uint _foregroundThreadId = 0; 29 | private readonly uint _currentThreadId = 0; 30 | 31 | private AttachedThreadInputScope() 32 | { 33 | s_current.Value?.Dispose(); 34 | s_current.Value = this; 35 | 36 | _foregroundWindow = WinApi.GetForegroundWindow(); 37 | _currentThreadId = WinApi.GetCurrentThreadId(); 38 | _foregroundThreadId = WinApi.GetWindowThreadProcessId(_foregroundWindow, out var _); 39 | 40 | if (_currentThreadId != _foregroundThreadId) 41 | { 42 | // attach to the foreground thread 43 | if (!WinApi.AttachThreadInput(_currentThreadId, _foregroundThreadId, true)) 44 | { 45 | // unable to attach, see if the window is a Win10 Console Window 46 | var className = new StringBuilder(capacity: 256); 47 | WinApi.GetClassName(_foregroundWindow, className, className.Capacity - 1); 48 | if (String.CompareOrdinal("ConsoleWindowClass", className.ToString()) != 0) 49 | { 50 | return; 51 | } 52 | // consider attached to a console window 53 | } 54 | } 55 | 56 | _attached = true; 57 | } 58 | 59 | void IDisposable.Dispose() 60 | { 61 | if (s_current.Value == this) 62 | { 63 | s_current.Value = null; 64 | if (_attached) 65 | { 66 | _attached = false; 67 | if (_currentThreadId != _foregroundThreadId) 68 | { 69 | WinApi.AttachThreadInput(_currentThreadId, _foregroundThreadId, false); 70 | } 71 | } 72 | } 73 | } 74 | 75 | public static AttachedThreadInputScope Create() 76 | { 77 | return new AttachedThreadInputScope(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /AppLogic/Config/HotkeyConfigSection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using AppLogic.Models; 10 | using System; 11 | using System.Configuration; 12 | using System.Linq; 13 | using System.Xml; 14 | 15 | namespace AppLogic.Config 16 | { 17 | internal class HotkeyConfigSection : IConfigurationSectionHandler 18 | { 19 | public object Create(object parent, object context, XmlNode section) 20 | { 21 | var result = new HotkeyCollection(); 22 | 23 | foreach (var node in section.ChildNodes 24 | .Cast() 25 | .Where(child => child.Name == "hotkey")) 26 | { 27 | void throwFormatException() => throw new FormatException(node.OuterXml); 28 | 29 | var name = node!.Attributes!["name"]?.Value; 30 | if (name.IsNullOrWhiteSpace()) 31 | { 32 | throwFormatException(); 33 | } 34 | 35 | var menuItem = node.Attributes["menuItem"]?.Value; 36 | 37 | uint? mods = null; 38 | var modsText = node.Attributes["mods"]?.Value; 39 | if (modsText != null) 40 | { 41 | if (!ParsingHelpers.TryParseHexOrDec(modsText, out var modsValue)) 42 | { 43 | throwFormatException(); 44 | } 45 | mods = (uint)modsValue; 46 | } 47 | 48 | uint? vkey = null; 49 | var vkeyText = node.Attributes["vkey"]?.Value; 50 | if (vkeyText != null) 51 | { 52 | if (!ParsingHelpers.TryParseHexOrDecOrChar(vkeyText, out var vkeyValue)) 53 | { 54 | throwFormatException(); 55 | } 56 | vkey = (uint)vkeyValue; 57 | } 58 | 59 | var isScript = false; 60 | var isScriptText = node.Attributes["isScript"]?.Value; 61 | if (isScriptText != null && !ParsingHelpers.TryParseBool(isScriptText, out isScript)) 62 | { 63 | throwFormatException(); 64 | } 65 | 66 | var addSeparator = false; 67 | var addSeparatorText = node.Attributes["hasSeparator"]?.Value; 68 | if (addSeparatorText != null && !ParsingHelpers.TryParseBool(addSeparatorText, out addSeparator)) 69 | { 70 | throwFormatException(); 71 | } 72 | 73 | result.Add(new Hotkey 74 | { 75 | Name = name!, 76 | MenuItem = menuItem, 77 | Mods = mods, 78 | Vkey = vkey, 79 | IsScript = isScript, 80 | AddSeparator = addSeparator, 81 | Data = node.InnerText 82 | }); 83 | } 84 | 85 | return result; 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /Tests/AsyncCoroutineProxy.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Runtime.CompilerServices; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using System.Threading.Channels; 14 | 15 | namespace Tests 16 | { 17 | public interface ICoroutineProxy 18 | { 19 | public Task> AsAsyncEnumerable(CancellationToken token = default); 20 | } 21 | 22 | public static class CoroutineProxyExt 23 | { 24 | public async static Task> AsAsyncEnumerator( 25 | this ICoroutineProxy @this, 26 | CancellationToken token = default) 27 | { 28 | return (await @this.AsAsyncEnumerable(token)).GetAsyncEnumerator(token); 29 | } 30 | 31 | public async static ValueTask GetNextAsync(this IAsyncEnumerator @this) 32 | { 33 | if (!await @this.MoveNextAsync()) 34 | { 35 | throw new IndexOutOfRangeException(nameof(GetNextAsync)); 36 | } 37 | return @this.Current; 38 | } 39 | 40 | public static Task GetNextAsync(this IAsyncEnumerator @this, CancellationToken token) 41 | { 42 | return @this.GetNextAsync().AsTask().ContinueWith>( 43 | continuationFunction: ante => ante, 44 | cancellationToken: token, 45 | continuationOptions: TaskContinuationOptions.ExecuteSynchronously, 46 | TaskScheduler.Default).Unwrap(); 47 | } 48 | 49 | public static Task Run( 50 | this AsyncCoroutineProxy @this, 51 | IAsyncApartment apartment, 52 | Func> routine, 53 | CancellationToken token) 54 | { 55 | return apartment.Run( 56 | () => @this.Run(routine, token), 57 | token); 58 | } 59 | } 60 | 61 | public class AsyncCoroutineProxy : ICoroutineProxy 62 | { 63 | readonly TaskCompletionSource> _proxyTcs = 64 | new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); 65 | 66 | public AsyncCoroutineProxy() 67 | { 68 | } 69 | 70 | async Task> ICoroutineProxy.AsAsyncEnumerable(CancellationToken token) 71 | { 72 | using var _ = token.Register(() => _proxyTcs.TrySetCanceled(), useSynchronizationContext: false); 73 | return await _proxyTcs.Task; 74 | } 75 | 76 | public async Task Run(Func> routine, CancellationToken token) 77 | { 78 | token.ThrowIfCancellationRequested(); 79 | var channel = Channel.CreateUnbounded(); 80 | var writer = channel.Writer; 81 | var proxy = channel.Reader.ReadAllAsync(token); 82 | _proxyTcs.SetResult(proxy); // throw if already set 83 | 84 | try 85 | { 86 | await foreach (var item in routine(token).WithCancellation(token)) 87 | { 88 | await writer.WriteAsync(item, token); 89 | } 90 | writer.Complete(); 91 | } 92 | catch (Exception ex) 93 | { 94 | writer.Complete(ex); 95 | throw; 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /AppLogic/Config/ParsingHelpers.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using System; 10 | using System.Diagnostics.CodeAnalysis; 11 | using System.Text.RegularExpressions; 12 | 13 | namespace AppLogic.Config 14 | { 15 | internal static class ParsingHelpers 16 | { 17 | //TODO: unit tests 18 | public static bool TryParseHexOrDec(string text, out int value) 19 | { 20 | // E.g.: 0x0A or 10 21 | var regex = new Regex(@"^\s*((0x(?-?[0-9A-F]+))|(?-?\d+))\s*$", 22 | RegexOptions.IgnoreCase | RegexOptions.Singleline); 23 | 24 | var match = regex.Match(text); 25 | if (match.Success) 26 | { 27 | var hex = match.Groups["hex"].Value; 28 | if (hex.IsNotEmpty()) 29 | { 30 | value = Convert.ToInt32(hex, 16); 31 | return true; 32 | } 33 | 34 | var dec = match.Groups["dec"].Value; 35 | if (dec.IsNotEmpty()) 36 | { 37 | value = Convert.ToInt32(dec, 10); 38 | return true; 39 | } 40 | } 41 | 42 | value = default; 43 | return false; 44 | } 45 | 46 | public static bool TryParseHexOrDecOrChar(string text, out int value) 47 | { 48 | // E.g.: 0x0A or 10 or 'A' 49 | var regex = new Regex(@"^\s*((0x(?-?[0-9A-F]+))|(?-?\d+)|('(?[0-9A-Z])'))\s*$", 50 | RegexOptions.IgnoreCase | RegexOptions.Singleline); 51 | 52 | var match = regex.Match(text); 53 | if (match.Success) 54 | { 55 | var hex = match.Groups["hex"].Value; 56 | if (hex.IsNotEmpty()) 57 | { 58 | value = Convert.ToInt32(hex, 16); 59 | return true; 60 | } 61 | 62 | var dec = match.Groups["dec"].Value; 63 | if (dec.IsNotEmpty()) 64 | { 65 | value = Convert.ToInt32(dec, 10); 66 | return true; 67 | } 68 | 69 | var character = match.Groups["char"].Value; 70 | if (character.IsNotEmpty()) 71 | { 72 | value = (int)character.ToUpper()[0]; 73 | return true; 74 | } 75 | } 76 | 77 | value = default; 78 | return false; 79 | } 80 | 81 | public static bool TryParseBool(string text, out bool value) 82 | { 83 | // E.g.: true or false 84 | var regex = new Regex(@"^\s*(?(true|false))\s*$", 85 | RegexOptions.IgnoreCase | RegexOptions.Singleline); 86 | 87 | var match = regex.Match(text); 88 | if (match.Success) 89 | { 90 | var boolText = match.Groups["bool"].Value; 91 | return Boolean.TryParse(boolText, out value); 92 | } 93 | 94 | value = default; 95 | return false; 96 | } 97 | 98 | public static bool TryParseKeyValue(string text, string key, [NotNullWhen(true)] out string? value) 99 | { 100 | // E.g.: "key: value;" 101 | var regex = new Regex($"(^|;)\\s*({key})\\s*:\\s*(?.+?)\\s*(;|$)", 102 | RegexOptions.Singleline); 103 | 104 | var group = regex.Match(text).Groups["value"]; 105 | value = group.Value; 106 | return group.Success; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /AppLogic/Presenter/ScriptHotkeyHandlers.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using AppLogic.Models; 10 | using Microsoft.CodeAnalysis.CSharp.Scripting; 11 | using Microsoft.CodeAnalysis.Scripting; 12 | using System; 13 | using System.Text; 14 | using System.Text.RegularExpressions; 15 | using System.Collections.Generic; 16 | using System.Linq; 17 | using System.Diagnostics.CodeAnalysis; 18 | using System.Threading; 19 | using System.Threading.Tasks; 20 | 21 | #nullable enable 22 | 23 | namespace AppLogic.Presenter 24 | { 25 | /// 26 | /// Script actions for hotkeys 27 | /// 28 | internal class ScriptHotkeyHandlers: IHotkeyHandlerProvider 29 | { 30 | public IHotkeyHandlerHost Host { get; } 31 | 32 | // C# scripts as hotkey handlers: 33 | // https://github.com/dotnet/roslyn/wiki/Scripting-API-Samples 34 | private readonly Lazy>> _scriptRunners = 35 | new Lazy>>(isThreadSafe: false); 36 | 37 | public ScriptHotkeyHandlers(IHotkeyHandlerHost host) 38 | { 39 | Host = host; 40 | } 41 | 42 | private static Lazy ScriptOptions { get; } = 43 | new Lazy(CreateScriptOptions, isThreadSafe: false); 44 | 45 | private static ScriptOptions CreateScriptOptions() 46 | { 47 | return Microsoft.CodeAnalysis.Scripting.ScriptOptions.Default 48 | .WithReferences( 49 | typeof(Enumerable).Assembly, 50 | typeof(CancellationToken).Assembly, 51 | typeof(StringBuilder).Assembly, 52 | typeof(Regex).Assembly) 53 | .WithImports( 54 | "System", 55 | "System.Text", 56 | "System.Text.RegularExpressions", 57 | "System.Collections.Generic", 58 | "System.Linq"); 59 | } 60 | 61 | public async Task ExecuteScript(Hotkey hotkey, CancellationToken token) 62 | { 63 | using var threadInputScope = AttachedThreadInputScope.Create(); 64 | using var waitCursor = WaitCursorScope.Create(); 65 | 66 | var scriptRunners = _scriptRunners.Value; 67 | if (!scriptRunners.TryGetValue(hotkey.Name, out var scriptRunner)) 68 | { 69 | var code = hotkey.Data!.ToString(); 70 | scriptRunner = await Task.Run(() => 71 | { 72 | var script = CSharpScript.Create( 73 | code: code, 74 | options: ScriptOptions.Value, 75 | globalsType: typeof(IScriptGlobals)); 76 | 77 | script.Compile(token); 78 | return script.CreateDelegate(); 79 | }, token); 80 | 81 | scriptRunners.Add(hotkey.Name, scriptRunner); 82 | } 83 | 84 | var globals = new ScriptGlobals(this.Host, token); 85 | var result = await scriptRunner(globals, token); 86 | if (result is Task task) 87 | { 88 | await task; 89 | } 90 | 91 | Host.PlayNotificationSound(); 92 | } 93 | 94 | bool IHotkeyHandlerProvider.CanHandle(Hotkey hotkey, [NotNullWhen(true)] out HotkeyHandlerCallback? callback) 95 | { 96 | if (hotkey.IsScript && hotkey.Data.IsNotNullNorWhiteSpace()) 97 | { 98 | callback = this.ExecuteScript; 99 | return true; 100 | } 101 | callback = default; 102 | return false; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /AppLogic/Helpers/ExceptionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Reflection; 12 | using System.Runtime.CompilerServices; 13 | using System.Threading; 14 | using System.Threading.Tasks; 15 | 16 | namespace AppLogic.Helpers 17 | { 18 | /// 19 | /// Exception extensions 20 | /// 21 | internal static partial class ExceptionExtensions 22 | { 23 | /// 24 | /// Unwrap a parent-child chain of single instance AggregateException exceptions 25 | /// 26 | public static Exception Unwrap(this Exception @this) 27 | { 28 | var inner = @this; 29 | while (true) 30 | { 31 | if (!(inner is AggregateException aggregate) || 32 | aggregate.InnerExceptions.Count != 1) 33 | { 34 | return inner; 35 | } 36 | inner = aggregate.InnerExceptions[0]; 37 | } 38 | } 39 | 40 | /// 41 | /// Exception as enumerable, with recursive flattening of any nested AggregateException 42 | /// 43 | public static IEnumerable AsEnumerable(this Exception? @this) 44 | { 45 | if (@this == null) 46 | { 47 | yield break; 48 | } 49 | 50 | if (@this is AggregateException aggregate) 51 | { 52 | if (aggregate.InnerExceptions.Count == 0) 53 | { 54 | // uncommon but possible: AggregateException without inner exceptions 55 | yield return aggregate; 56 | } 57 | else if (aggregate.InnerExceptions.Count == 1 && !(aggregate.InnerException is AggregateException)) 58 | { 59 | // the most common case: one wrapped exception which is not AggregateException 60 | yield return aggregate.InnerExceptions[0]; 61 | } 62 | else 63 | { 64 | // yield all of inner exceptions recursively 65 | foreach (var child in aggregate.InnerExceptions.SelectMany(inner => inner.AsEnumerable())) 66 | { 67 | yield return child; 68 | } 69 | } 70 | } 71 | else 72 | { 73 | yield return @this; 74 | } 75 | } 76 | 77 | /// 78 | /// Suppress "is never used" warning, use it only if you really mean it. 79 | /// 80 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 81 | public static void Unused(this Exception _) 82 | { 83 | } 84 | 85 | /// 86 | /// True if the exception is an instance of OperationCanceledException (or a derived class) 87 | /// 88 | public static bool IsOperationCanceled(this Exception? @this) 89 | { 90 | if (@this == null) 91 | { 92 | return false; 93 | } 94 | if (@this is OperationCanceledException) 95 | { 96 | return true; 97 | } 98 | if (@this is AggregateException aggregate) 99 | { 100 | if (aggregate.InnerExceptions.Count == 1 && aggregate.InnerExceptions[0] is OperationCanceledException) 101 | { 102 | return true; 103 | } 104 | return !aggregate.AsEnumerable().Any(ex => !(ex is OperationCanceledException)); 105 | } 106 | return false; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /AppLogic/Helpers/InputUtils.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace AppLogic.Helpers 13 | { 14 | internal static class InputUtils 15 | { 16 | public const int MIN_DELAY = WinApi.USER_TIMER_MINIMUM; 17 | 18 | public static bool AnyInputMessage(uint qsFlags) 19 | { 20 | // the high-order word of the return value indicates the types of messages currently in the queue, 21 | // including already observed but not retrieved messages; 22 | // beware of the undoc QS_EVENT, it may always be returning non-zero 23 | uint status = WinApi.GetQueueStatus(qsFlags); 24 | return (status >> 16) != 0; 25 | } 26 | 27 | /// 28 | /// Yield to the message loop via a low-priority WM_TIMER message 29 | /// https://web.archive.org/web/20130627005845/http://support.microsoft.com/kb/96006 30 | /// All input messages are processed before WM_TIMER and WM_PAINT messages. 31 | /// Unlike with System.Windows.Forms.Timer, we don't need a hidden window for this. 32 | /// 33 | [System.Diagnostics.DebuggerNonUserCode] 34 | public static async ValueTask TimerYield( 35 | int delay = MIN_DELAY, 36 | CancellationToken token = default) 37 | { 38 | // It is possible to reduce the ammount of allocations by 39 | // using a custom awaiter or a pooled/cached implementation of IValueTaskSource 40 | // instead of TaskCompletionSource, as well as TimerProc, 41 | // but that'd be an overkill for our use case 42 | 43 | token.ThrowIfCancellationRequested(); 44 | 45 | var tcs = new TaskCompletionSource(); 46 | 47 | WinApi.TimerProc timerProc = delegate 48 | { 49 | if (token.IsCancellationRequested) 50 | tcs.TrySetCanceled(); 51 | else 52 | tcs.TrySetResult(DBNull.Value); 53 | }; 54 | 55 | var gch = System.Runtime.InteropServices.GCHandle.Alloc(timerProc); 56 | try 57 | { 58 | var timerId = WinApi.SetTimer(IntPtr.Zero, IntPtr.Zero, (uint)delay, timerProc); 59 | if (timerId == IntPtr.Zero) 60 | throw new InvalidOperationException(); 61 | try 62 | { 63 | using var rego = token.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false); 64 | await tcs.Task; 65 | } 66 | finally 67 | { 68 | WinApi.KillTimer(IntPtr.Zero, timerId); 69 | } 70 | } 71 | finally 72 | { 73 | gch.Free(); 74 | } 75 | } 76 | 77 | /// 78 | /// timer-based yielding to keep the UI responsive 79 | /// 80 | public static async ValueTask InputYield( 81 | uint qsFlags = WinApi.QS_INPUT | WinApi.QS_POSTMESSAGE, 82 | int delay = MIN_DELAY, 83 | CancellationToken token = default) 84 | { 85 | token.ThrowIfCancellationRequested(); 86 | 87 | while (true) 88 | { 89 | // yield via a low-priority WM_TIMER message, 90 | // all keyboard and mouse messages should have been pumped 91 | // before WM_TIMER is posted 92 | await TimerYield(delay, token); 93 | 94 | // exit if there is no pending input in the Windows message queue to process 95 | if (!AnyInputMessage(qsFlags)) 96 | break; 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/WinFormsApartment.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using System; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using System.Windows.Forms; 13 | 14 | namespace Tests 15 | { 16 | /// 17 | /// Test WinFroms stuff on a dedicated thread with proper WindowsFormsSynchronizationContext 18 | /// 19 | public class WinFormsApartment : AsyncApartmentBase 20 | { 21 | private readonly Thread _thread; // an STA thread for WinForms 22 | 23 | public override Task Completion { get; } 24 | 25 | protected override TaskScheduler TaskScheduler { get; } 26 | 27 | public override bool? AnyBackgroundOperation => null; 28 | 29 | /// MessageLoopApartment constructor 30 | public WinFormsApartment() 31 | { 32 | var startTcs = new TaskCompletionSource(); 33 | 34 | // start an STA thread with WindowsFormsSynchronizationContext 35 | // and gets a task scheduler for it 36 | void threadStart() 37 | { 38 | try 39 | { 40 | WindowsFormsSynchronizationContext.AutoInstall = false; 41 | Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); 42 | using (var context = new WindowsFormsSynchronizationContext()) 43 | { 44 | SynchronizationContext.SetSynchronizationContext(context); 45 | Application.ThreadException += ThreadExceptionHandler; 46 | startTcs.TrySetResult(TaskScheduler.FromCurrentSynchronizationContext()); 47 | try 48 | { 49 | using (CreateAsyncScope()) 50 | { 51 | Application.Run(); 52 | } 53 | } 54 | catch (Exception ex) 55 | { 56 | AddException(ex); 57 | } 58 | finally 59 | { 60 | Application.ThreadException -= ThreadExceptionHandler; 61 | SynchronizationContext.SetSynchronizationContext(null); 62 | TrySetCompletion(); 63 | } 64 | } 65 | } 66 | catch (Exception ex) 67 | { 68 | if (!startTcs.TrySetException(ex)) 69 | throw; 70 | } 71 | } 72 | 73 | Task waitForCompletionAsync() => 74 | GetCompletionTask().ContinueWith( 75 | anteTask => { _thread.Join(); return anteTask; }, 76 | cancellationToken: CancellationToken.None, 77 | TaskContinuationOptions.ExecuteSynchronously, 78 | scheduler: TaskScheduler.Default).Unwrap(); 79 | 80 | _thread = new Thread(threadStart); 81 | _thread.SetApartmentState(ApartmentState.STA); 82 | _thread.IsBackground = true; 83 | _thread.Start(); 84 | 85 | this.TaskScheduler = startTcs.Task.Result; 86 | this.Completion = waitForCompletionAsync(); 87 | } 88 | 89 | protected override void ConcludeCompletion() 90 | { 91 | if (_thread.IsAlive) 92 | { 93 | Task.Factory.StartNew( 94 | () => Application.ExitThread(), 95 | CancellationToken.None, 96 | TaskCreationOptions.None, 97 | this.TaskScheduler).Observe(); 98 | } 99 | } 100 | 101 | private void ThreadExceptionHandler(object s, System.Threading.ThreadExceptionEventArgs e) 102 | { 103 | AddException(e.Exception); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /AppLogic/Helpers/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Text.RegularExpressions; 12 | using System.Text.Encodings.Web; 13 | 14 | namespace AppLogic.Helpers 15 | { 16 | /// 17 | /// String extension for easy fluent syntax chains 18 | /// TODO: unit tests, make Regexes static 19 | /// 20 | internal static class StringExtensions 21 | { 22 | public static string AsString(this IEnumerable @this) 23 | { 24 | return String.Concat(@this); 25 | } 26 | 27 | public static string UnixifyLineEndings(this string @this) 28 | { 29 | // use only "\n" for line breaks 30 | return Regex.Replace(@this, @"(\r\n)|(\n\r)|(\r)", "\n", RegexOptions.Singleline); 31 | } 32 | 33 | public static string WindowsifyLineEndings(this string @this) 34 | { 35 | // use "\r\n" for line breaks 36 | return @this.Replace("\n", Environment.NewLine); 37 | } 38 | 39 | public static string RemoveSpaces(this string @this) 40 | { 41 | return String.Concat(@this.Where(c => !Char.IsWhiteSpace(c))); 42 | } 43 | 44 | public static string TrimTrailingEmptyLines(this string @this) 45 | { 46 | var regex = new Regex(@"^\s*$", RegexOptions.Singleline); 47 | 48 | var lines = @this.Split('\n') 49 | .SkipWhile(l => regex.IsMatch(l)) 50 | .Reverse() 51 | .SkipWhile(l => regex.IsMatch(l)) 52 | .Reverse(); 53 | 54 | return String.Join('\n', lines); 55 | } 56 | 57 | public static string ConvertToSingleLine(this string @this) 58 | { 59 | return String.Join('\x20', @this.Split('\n').Select(l => l.Trim())); 60 | } 61 | 62 | public static string TabifyStart(this string @this, int tabSize) 63 | { 64 | var spaces = new String('\x20', tabSize); 65 | var regex = new Regex(@"^(\s+)(.+?)\s*$", RegexOptions.Multiline); 66 | return regex.Replace(@this, m => 67 | $"{m.Groups[1].Value.Replace(spaces, "\t")}{m.Groups[2].Value}"); 68 | } 69 | 70 | public static string UntabifyStart(this string @this, int tabSize) 71 | { 72 | var spaces = new String('\x20', tabSize); 73 | var regex = new Regex(@"^(\s+)(.+?)\s*$", RegexOptions.Multiline); 74 | return regex.Replace(@this, m => 75 | $"{m.Groups[1].Value.Replace("\t", spaces)}{m.Groups[2].Value}"); 76 | } 77 | 78 | public static string Unindent(this string @this) 79 | { 80 | var regexSpace = new Regex(@"^[\x20\t]+", RegexOptions.Singleline); 81 | 82 | var lines = @this.Split('\n'); 83 | 84 | var indentSize = lines.Aggregate( 85 | int.MaxValue, 86 | (minSize, line) => 87 | regexSpace.Match(line) is var match && match.Success ? 88 | (match.Value.Length is var size && size < minSize ? size : minSize) : 89 | 0); 90 | 91 | if (indentSize == 0 || indentSize == int.MaxValue) 92 | { 93 | return @this; 94 | } 95 | 96 | var removalRegex = new Regex($"^[\\x20\\t]{{{indentSize}}}", RegexOptions.Singleline); 97 | return String.Join('\n', lines.Select(l => removalRegex.Replace(l, String.Empty))); 98 | } 99 | 100 | public static string ToPreformattedHtml(this string @this) 101 | { 102 | return 103 | "
{HtmlEncoder.Default.Encode(@this)}\r\n
"; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Tests/ThreadPoolApartment.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace Tests 13 | { 14 | /// 15 | /// Queue callback items posted to SynchronizationContext.Post for execution 16 | /// on ThreadPool and collect any thrown exceptions. 17 | /// Used for testing async/await behaviors, including async void methods. 18 | /// It also imposes asynchronous continuations for all await within its scope, 19 | /// the reasons behind this is essentially the same as with RunContinuationsAsynchronously: 20 | /// https://tinyurl.com/RunContinuationsAsynchronously 21 | /// 22 | public class ThreadPoolApartment : AsyncApartmentBase 23 | { 24 | #region Helpers 25 | 26 | internal class ThreadPoolSyncContext : SynchronizationContext 27 | { 28 | private readonly ThreadPoolApartment _apartment; 29 | 30 | public TaskScheduler TaskScheduler { get; } 31 | 32 | public ThreadPoolSyncContext(ThreadPoolApartment apartment) 33 | { 34 | _apartment = apartment; 35 | this.TaskScheduler = WithContext(() => TaskScheduler.FromCurrentSynchronizationContext()); 36 | } 37 | 38 | protected void WithContext(Action action) 39 | { 40 | var savedContext = SynchronizationContext.Current; 41 | SynchronizationContext.SetSynchronizationContext(this); 42 | try 43 | { 44 | action(); 45 | } 46 | finally 47 | { 48 | SynchronizationContext.SetSynchronizationContext(savedContext); 49 | } 50 | } 51 | 52 | protected T WithContext(Func func) 53 | { 54 | var savedContext = SynchronizationContext.Current; 55 | SynchronizationContext.SetSynchronizationContext(this); 56 | try 57 | { 58 | return func(); 59 | } 60 | finally 61 | { 62 | SynchronizationContext.SetSynchronizationContext(savedContext); 63 | } 64 | } 65 | 66 | public override void Send(SendOrPostCallback d, object? state) 67 | { 68 | throw new NotImplementedException(nameof(Send)); 69 | } 70 | 71 | public override SynchronizationContext CreateCopy() 72 | { 73 | return this; 74 | } 75 | 76 | public override void OperationStarted() 77 | { 78 | _apartment.OperationStarted(); 79 | } 80 | 81 | public override void OperationCompleted() 82 | { 83 | _apartment.OperationCompleted(); 84 | } 85 | 86 | public override void Post(SendOrPostCallback d, object? state) 87 | { 88 | void callback(object? s) 89 | { 90 | try 91 | { 92 | WithContext(() => d(s)); 93 | } 94 | catch (Exception ex) 95 | { 96 | _apartment.AddException(ex); 97 | } 98 | finally 99 | { 100 | OperationCompleted(); 101 | } 102 | } 103 | 104 | OperationStarted(); 105 | ThreadPool.UnsafeQueueUserWorkItem(callback, state, preferLocal: false); 106 | } 107 | } 108 | 109 | #endregion 110 | 111 | #region State 112 | 113 | private readonly ThreadPoolSyncContext _syncContext; 114 | 115 | protected override TaskScheduler TaskScheduler { get; } 116 | 117 | #endregion 118 | 119 | public ThreadPoolApartment() 120 | { 121 | _syncContext = new ThreadPoolSyncContext(this); 122 | this.TaskScheduler = _syncContext.TaskScheduler; 123 | } 124 | 125 | protected override void ConcludeCompletion() 126 | { 127 | TrySetCompletion(); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /AppLogic/Config/Configuration.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Models; 9 | using System; 10 | using System.Configuration; 11 | using System.Diagnostics.CodeAnalysis; 12 | using System.IO; 13 | using System.Reflection; 14 | using System.Text; 15 | 16 | namespace AppLogic.Config 17 | { 18 | /// 19 | /// Local settings might get overwritten every time the app is upgraded 20 | /// Roaming settings are for customization, they doesn't get overwritten 21 | /// and they have precedence over Local settings 22 | /// 23 | internal static class Configuration 24 | { 25 | public static OptionCollection LocalOptions => _localOptions.Value; 26 | public static OptionCollection RoamingOptions => _roamingOptions.Value; 27 | 28 | public static HotkeyCollection LocalHotkeys => _localHotkeys.Value; 29 | public static HotkeyCollection RoamingHotkeys => _roamingHotkeys.Value; 30 | 31 | public static string LocalConfigPath => _localConfig.Value.FilePath; 32 | public static string RoamingConfigPath => _roamingConfig.Value.FilePath; 33 | 34 | private static readonly Lazy _localConfig; 35 | private static readonly Lazy _roamingConfig; 36 | 37 | private static readonly Lazy _localOptions; 38 | private static readonly Lazy _roamingOptions; 39 | 40 | private static readonly Lazy _localHotkeys; 41 | private static readonly Lazy _roamingHotkeys; 42 | 43 | static Configuration() 44 | { 45 | // lazy initialization for proper error reporting 46 | _localConfig = new Lazy(() => 47 | ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None), 48 | isThreadSafe: false); 49 | _roamingConfig = new Lazy(() => 50 | ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.PerUserRoaming), 51 | isThreadSafe: false); 52 | 53 | const string optionsSection = "options"; 54 | _localOptions = new Lazy(() => 55 | _localConfig.Value.GetCustomSection(optionsSection) ?? new OptionCollection(), 56 | isThreadSafe: false); 57 | _roamingOptions = new Lazy(() => 58 | _roamingConfig.Value.GetCustomSection(optionsSection) ?? new OptionCollection(), 59 | isThreadSafe: false); 60 | 61 | const string hotkeysSection = "hotkeys"; 62 | _localHotkeys = new Lazy(() => 63 | _localConfig.Value.GetCustomSection(hotkeysSection) ?? new HotkeyCollection(), 64 | isThreadSafe: false); 65 | _roamingHotkeys = new Lazy(() => 66 | _roamingConfig.Value.GetCustomSection(hotkeysSection) ?? new HotkeyCollection(), 67 | isThreadSafe: false); 68 | } 69 | 70 | public static bool TryGetOption(string name, [NotNullWhen(true)] out string? value) 71 | { 72 | if (LocalOptions.TryGetValue(name, out value)) 73 | { 74 | return true; 75 | } 76 | if (RoamingOptions.TryGetValue(name, out value)) 77 | { 78 | return true; 79 | } 80 | value = default; 81 | return false; 82 | } 83 | 84 | public static string GetOption(string name, string defaultValue) 85 | { 86 | if (TryGetOption(name, out var value)) 87 | { 88 | return value; 89 | } 90 | return defaultValue; 91 | } 92 | 93 | public static bool GetOption(string name, bool defaultValue) 94 | { 95 | if (TryGetOption(name, out var textValue)) 96 | { 97 | if (ParsingHelpers.TryParseBool(textValue, out var value)) 98 | { 99 | return value; 100 | } 101 | } 102 | return defaultValue; 103 | } 104 | 105 | public static int GetOption(string name, int defaultValue) 106 | { 107 | if (TryGetOption(name, out var textValue)) 108 | { 109 | if (int.TryParse(textValue, out var value)) 110 | { 111 | return value; 112 | } 113 | } 114 | return defaultValue; 115 | } 116 | 117 | /// 118 | /// Template for roaming user settings 119 | /// 120 | public static string GetDefaultRoamingConfig() 121 | { 122 | var resource = $"{nameof(AppLogic)}.User.config"; 123 | using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resource); 124 | if (stream == null) 125 | { 126 | throw new FileNotFoundException(resource); 127 | } 128 | using var reader = new StreamReader(stream, Encoding.UTF8); 129 | return reader.ReadToEnd(); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /AppLogic/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using AppLogic.Presenter; 10 | using System; 11 | using System.Diagnostics; 12 | using System.Reflection; 13 | using System.Runtime.CompilerServices; 14 | using System.Security.AccessControl; 15 | using System.Security.Principal; 16 | using System.Threading; 17 | using System.Windows.Forms; 18 | 19 | [assembly: InternalsVisibleTo("Tests")] 20 | [assembly: InternalsVisibleTo("DevComrade")] 21 | [assembly: AssemblyCopyright("Copyright (c) 2020 Postprintum Pty Ltd")] 22 | 23 | namespace AppLogic 24 | { 25 | internal static partial class Program 26 | { 27 | // cancellation of the RunAsync event loop 28 | private static CancellationTokenSource RuntimeCts { get; } = new CancellationTokenSource(); 29 | 30 | private static void Stop() => RuntimeCts.Cancel(); 31 | 32 | private static async void RunAsync() 33 | { 34 | // we're just a little sys tray app without a main window 35 | try 36 | { 37 | using var container = new HotkeyHandlerHost(RuntimeCts.Token); 38 | await container.AsTask(); 39 | } 40 | catch (Exception ex) 41 | { 42 | if (ex.IsOperationCanceled()) 43 | { 44 | // absorb cancellations 45 | Trace.WriteLine(ex.Message); 46 | } 47 | else 48 | { 49 | // handle here or re-throw and threadExceptionHandler will handle it 50 | ObserveError(ex); 51 | } 52 | } 53 | finally 54 | { 55 | Application.Exit(); 56 | } 57 | } 58 | 59 | private static void ObserveError(Exception ex) 60 | { 61 | // can use DbgView to view the trace: https://live.sysinternals.com/Dbgview.exe 62 | Trace.TraceError(ex.ToString()); 63 | MessageBox.Show(ex.Message, Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Error); 64 | Environment.ExitCode = 1; 65 | } 66 | 67 | private static void ThreadExceptionHandler(object s, System.Threading.ThreadExceptionEventArgs e) 68 | { 69 | ObserveError(e.Exception); 70 | // don't exit if it's a non-critical clipboard error 71 | if (!ClipboardAccess.IsClipboardError(e.Exception)) 72 | { 73 | Stop(); 74 | } 75 | } 76 | 77 | private static readonly Guid mutexGuid = new Guid( 78 | 0xe7a2ee5a, 0xc826, 0x4152, 0x9d, 0x0, 0x20, 0xfb, 0x19, 0x99, 0xdd, 0x9a); 79 | 80 | private static Mutex CreateAppMutex() 81 | { 82 | var mutex = new Mutex(false, mutexGuid.ToString()); 83 | var mutexSecurity = new MutexSecurity(); 84 | var allowEveryoneRule = new MutexAccessRule( 85 | new SecurityIdentifier(WellKnownSidType.WorldSid, null), 86 | MutexRights.FullControl, AccessControlType.Allow); 87 | mutexSecurity.AddAccessRule(allowEveryoneRule); 88 | mutex.SetAccessControl(mutexSecurity); 89 | return mutex; 90 | } 91 | 92 | private static void Run() 93 | { 94 | WindowsFormsSynchronizationContext.AutoInstall = false; 95 | WinUtils.EnableMenuShortcutsUnderlining(); 96 | 97 | Application.EnableVisualStyles(); 98 | Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); 99 | Application.SetCompatibleTextRenderingDefault(false); 100 | Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); 101 | 102 | using (var context = new WindowsFormsSynchronizationContext()) 103 | { 104 | SynchronizationContext.SetSynchronizationContext(context); 105 | Application.ThreadException += ThreadExceptionHandler; 106 | try 107 | { 108 | Environment.ExitCode = 0; 109 | context.Post(_ => RunAsync(), null); 110 | Application.Run(); 111 | } 112 | catch (Exception ex) 113 | { 114 | ObserveError(ex); 115 | } 116 | finally 117 | { 118 | Application.ThreadException -= ThreadExceptionHandler; 119 | SynchronizationContext.SetSynchronizationContext(null); 120 | } 121 | } 122 | } 123 | 124 | [STAThread] 125 | public static void Main(string[] args) 126 | { 127 | // make sure we don't run multiple instances 128 | using var mutex = CreateAppMutex(); 129 | if (!mutex.WaitOne(TimeSpan.FromSeconds(1))) 130 | { 131 | Trace.WriteLine("Another instance is already running."); 132 | Environment.ExitCode = 1; 133 | } 134 | else 135 | { 136 | // acquired the mutex 137 | try 138 | { 139 | Run(); 140 | Environment.ExitCode = 0; 141 | } 142 | finally 143 | { 144 | mutex.ReleaseMutex(); 145 | } 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/AsyncCoroutineProxyTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using AppLogic.Helpers; 9 | using Microsoft.VisualStudio.TestTools.UnitTesting; 10 | using System.Collections.Generic; 11 | using System.Diagnostics; 12 | using System.Runtime.CompilerServices; 13 | using System.Threading; 14 | using System.Threading.Tasks; 15 | 16 | namespace Tests 17 | { 18 | [TestClass] 19 | public class AsyncCoroutineProxyTest 20 | { 21 | const string TRACE_CATEGORY = "coroutines"; 22 | 23 | /// 24 | /// CoroutineA yields to CoroutineB 25 | /// 26 | private async IAsyncEnumerable CoroutineA( 27 | ICoroutineProxy coroutineProxy, 28 | [EnumeratorCancellation] CancellationToken token) 29 | { 30 | await using var coroutine = await coroutineProxy.AsAsyncEnumerator(token); 31 | 32 | const string name = "A"; 33 | var i = 0; 34 | 35 | // yielding 1 36 | Trace.WriteLine($"{name} about to yeild: {++i}", TRACE_CATEGORY); 37 | yield return $"{i} from {name}"; 38 | 39 | // receiving 40 | if (!await coroutine.MoveNextAsync()) 41 | { 42 | yield break; 43 | } 44 | Trace.WriteLine($"{name} received: {coroutine.Current}", TRACE_CATEGORY); 45 | 46 | // yielding 2 47 | Trace.WriteLine($"{name} about to yeild: {++i}", TRACE_CATEGORY); 48 | yield return $"{i} from {name}"; 49 | 50 | // receiving 51 | if (!await coroutine.MoveNextAsync()) 52 | { 53 | yield break; 54 | } 55 | Trace.WriteLine($"{name} received: {coroutine.Current}", TRACE_CATEGORY); 56 | 57 | // yielding 3 58 | Trace.WriteLine($"{name} about to yeild: {++i}", TRACE_CATEGORY); 59 | yield return $"{i} from {name}"; 60 | } 61 | 62 | /// 63 | /// CoroutineB yields to CoroutineA 64 | /// 65 | private async IAsyncEnumerable CoroutineB( 66 | ICoroutineProxy coroutineProxy, 67 | [EnumeratorCancellation] CancellationToken token) 68 | { 69 | await using var coroutine = await coroutineProxy.AsAsyncEnumerator(token); 70 | 71 | const string name = "B"; 72 | var i = 0; 73 | 74 | // receiving 75 | if (!await coroutine.MoveNextAsync()) 76 | { 77 | yield break; 78 | } 79 | Trace.WriteLine($"{name} received: {coroutine.Current}", TRACE_CATEGORY); 80 | 81 | // yielding 1 82 | Trace.WriteLine($"{name} about to yeild: {++i}", TRACE_CATEGORY); 83 | yield return $"{i} from {name}"; 84 | 85 | // receiving 86 | if (!await coroutine.MoveNextAsync()) 87 | { 88 | yield break; 89 | } 90 | Trace.WriteLine($"{name} received: {coroutine.Current}", TRACE_CATEGORY); 91 | 92 | // yielding 2 93 | Trace.WriteLine($"{name} about to yeild: {++i}", TRACE_CATEGORY); 94 | yield return $"{i} from {name}"; 95 | 96 | // receiving 97 | if (!await coroutine.MoveNextAsync()) 98 | { 99 | yield break; 100 | } 101 | Trace.WriteLine($"{name} received: {coroutine.Current}", TRACE_CATEGORY); 102 | } 103 | 104 | /// 105 | /// Testing CoroutineA and CoroutineB cooperative execution 106 | /// 107 | [TestMethod] 108 | public async Task test_two_coroutines_execution_flow() 109 | { 110 | // Here we execute two cotoutines, CoroutineA and CoroutineB, 111 | // which asynchronously yield to each other 112 | 113 | //TODO: test cancellation scenarios 114 | var token = CancellationToken.None; 115 | 116 | // use ThreadPoolApartment to impose asynchronous continuations for all awaits, 117 | // regardless if the task has completed synchronously 118 | // the reasoning behind this is essentially the same as for 119 | // the TaskContinuationOptions.RunContinuationsAsynchronously option: 120 | // https://tinyurl.com/RunContinuationsAsynchronously 121 | 122 | await using var apartment = new Tests.ThreadPoolApartment(); 123 | await apartment.Run(async () => 124 | { 125 | var proxyA = new AsyncCoroutineProxy(); 126 | var proxyB = new AsyncCoroutineProxy(); 127 | 128 | var listener = new Tests.CategoryTraceListener(TRACE_CATEGORY); 129 | Trace.Listeners.Add(listener); 130 | try 131 | { 132 | // start both coroutines 133 | await Task.WhenAll( 134 | proxyA.Run(token => CoroutineA(proxyB, token), token), 135 | proxyB.Run(token => CoroutineB(proxyA, token), token)) 136 | .WithAggregatedExceptions(); 137 | } 138 | finally 139 | { 140 | Trace.Listeners.Remove(listener); 141 | } 142 | 143 | var traces = listener.ToArray(); 144 | Assert.AreEqual(traces[0], "A about to yeild: 1"); 145 | Assert.AreEqual(traces[1], "B received: 1 from A"); 146 | Assert.AreEqual(traces[2], "B about to yeild: 1"); 147 | Assert.AreEqual(traces[3], "A received: 1 from B"); 148 | Assert.AreEqual(traces[4], "A about to yeild: 2"); 149 | Assert.AreEqual(traces[5], "B received: 2 from A"); 150 | Assert.AreEqual(traces[6], "B about to yeild: 2"); 151 | Assert.AreEqual(traces[7], "A received: 2 from B"); 152 | Assert.AreEqual(traces[8], "A about to yeild: 3"); 153 | Assert.AreEqual(traces[9], "B received: 3 from A"); 154 | }); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /AppLogic/Helpers/KeyboardInput.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 by Postprintum Pty Ltd (https://www.postprintum.com), 2 | // which licenses this file to you under Apache License 2.0, 3 | // see the LICENSE file in the project root for more information. 4 | // Author: Andrew Nosenko (@noseratio) 5 | 6 | #nullable enable 7 | 8 | using System; 9 | using System.Linq; 10 | using System.Runtime.InteropServices; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace AppLogic.Helpers 15 | { 16 | internal static class KeyboardInput 17 | { 18 | const int CHAR_FEED_DELAY = WinApi.USER_TIMER_MINIMUM; 19 | const int KEYBOARD_POLL_DELAY = WinApi.USER_TIMER_MINIMUM; 20 | const uint QS_KEYBOARD = WinApi.QS_KEY | WinApi.QS_HOTKEY; 21 | 22 | static readonly SemaphoreSlim s_asyncLock = new SemaphoreSlim(1); 23 | 24 | private static void SimulateKeyDown(uint vKey, bool extended = false) 25 | { 26 | var scancode = (byte)WinApi.MapVirtualKey(vKey, WinApi.MAPVK_VK_TO_VSC); 27 | WinApi.keybd_event((byte)vKey, (byte)scancode, 28 | extended ? WinApi.KEYEVENTF_EXTENDEDKEY : 0, 0); 29 | } 30 | 31 | private static void SimulateKeyUp(uint vKey, bool extended = false) 32 | { 33 | var scancode = (byte)WinApi.MapVirtualKey(vKey, WinApi.MAPVK_VK_TO_VSC); 34 | WinApi.keybd_event((byte)vKey, (byte)scancode, 35 | (extended ? WinApi.KEYEVENTF_EXTENDEDKEY : 0) | WinApi.KEYEVENTF_KEYUP, 0); 36 | } 37 | 38 | private static int[] AllKeys => s_allKeys.Value; 39 | 40 | private static Lazy s_allKeys = new Lazy(() => 41 | { 42 | // ignore some toogle keys (CapsLock etc) when we check if all keys are de-pressed 43 | var toogleKeys = new[] 44 | { 45 | // https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes 46 | WinApi.VK_NUMLOCK, WinApi.VK_SCROLL, WinApi.VK_CAPITAL, 47 | 0xE7, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1F, 0xFF 48 | }; 49 | return Enumerable.Range(1, 256).Where(key => !toogleKeys.Contains(key)).ToArray(); 50 | }, LazyThreadSafetyMode.None); 51 | 52 | public static bool IsKeyPressed(int key) 53 | { 54 | return (WinApi.GetAsyncKeyState(key) & 0x8000) != 0; 55 | } 56 | 57 | public static bool IsAnyKeyPressed() 58 | { 59 | return AllKeys.Any(key => IsKeyPressed(key)); 60 | } 61 | 62 | private static void ClearKeyboardState() 63 | { 64 | foreach (var key in AllKeys) 65 | { 66 | if (IsKeyPressed(key)) 67 | { 68 | SimulateKeyUp((uint)key, extended: true); 69 | SimulateKeyUp((uint)key); 70 | } 71 | } 72 | } 73 | 74 | public static async Task WaitForAllKeysReleasedAsync(CancellationToken token) 75 | { 76 | await s_asyncLock.WaitAsync(token); 77 | try 78 | { 79 | ClearKeyboardState(); 80 | 81 | while (true) 82 | { 83 | if (IsKeyPressed(WinApi.VK_ESCAPE)) 84 | { 85 | throw new TaskCanceledException(); 86 | } 87 | if (!IsAnyKeyPressed()) 88 | { 89 | break; 90 | } 91 | await InputUtils.InputYield(delay: KEYBOARD_POLL_DELAY, token: token); 92 | } 93 | } 94 | finally 95 | { 96 | s_asyncLock.Release(); 97 | } 98 | } 99 | 100 | private static void CharToKeyboardInput(char c, ref WinApi.INPUT input) 101 | { 102 | input.type = WinApi.INPUT_KEYBOARD; 103 | input.union.keyboard.wVk = 0; 104 | input.union.keyboard.wScan = (ushort)c; 105 | input.union.keyboard.dwFlags = WinApi.KEYEVENTF_UNICODE; 106 | input.union.keyboard.time = 0; 107 | input.union.keyboard.dwExtraInfo = UIntPtr.Zero; 108 | } 109 | 110 | public static async Task FeedTextAsync(string text, CancellationToken token) 111 | { 112 | await s_asyncLock.WaitAsync(token); 113 | try 114 | { 115 | var foregroundWindow = WinApi.GetForegroundWindow(); 116 | WinApi.BlockInput(true); 117 | try 118 | { 119 | var size = Marshal.SizeOf(); 120 | var input = new WinApi.INPUT[1]; 121 | 122 | // feed each character individually and asynchronously 123 | foreach (var c in text) 124 | { 125 | token.ThrowIfCancellationRequested(); 126 | 127 | if (WinApi.GetForegroundWindow() != foregroundWindow || 128 | IsAnyKeyPressed()) 129 | { 130 | break; 131 | } 132 | 133 | if (c == '\n' || c == '\r') 134 | { 135 | // we need this for correctly handling line breaks 136 | // when pasting into Chromium's 233 | 234 | "; 235 | 236 | private class CustomWebBrowser : WebBrowser 237 | { 238 | private readonly Notepad _parent; 239 | 240 | public CustomWebBrowser(Notepad parent) 241 | { 242 | _parent = parent; 243 | } 244 | 245 | protected override void OnPreviewKeyDown(PreviewKeyDownEventArgs e) 246 | { 247 | if (e.Modifiers == Keys.Control && e.KeyCode == Keys.Enter) 248 | { 249 | e.IsInputKey = true; 250 | this._parent.OnControlEnterPressed(); 251 | return; 252 | } 253 | 254 | // ignore these keys 255 | if (e.Control && 256 | (e.KeyCode == Keys.N || e.KeyCode == Keys.L || 257 | e.KeyCode == Keys.O || e.KeyCode == Keys.P) 258 | || e.KeyCode == Keys.F5) 259 | { 260 | e.IsInputKey = true; 261 | return; 262 | } 263 | 264 | base.OnPreviewKeyDown(e); 265 | } 266 | } 267 | 268 | #pragma warning disable CS0618 // InterfaceIsIDispatch is obsolete 269 | private const string IID_IDispatch = "00020400-0000-0000-C000-000000000046"; 270 | 271 | [ComVisible(true), Guid(IID_IDispatch), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] 272 | private interface IDocument 273 | { 274 | IElement getElementById(string id); 275 | bool execCommand(string command, bool showUI, object value); 276 | ISelection selection { get; } 277 | } 278 | 279 | [ComVisible(true), Guid(IID_IDispatch), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] 280 | private interface IElement 281 | { 282 | void focus(); 283 | } 284 | 285 | [ComVisible(true), Guid(IID_IDispatch), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] 286 | private interface ITextArea 287 | { 288 | void focus(); 289 | string value { get; set; } 290 | IRange createTextRange(); 291 | } 292 | 293 | [ComVisible(true), Guid(IID_IDispatch), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] 294 | private interface IBrowser 295 | { 296 | IDocument Document { get; } 297 | int ReadyState { get; } 298 | } 299 | 300 | [ComVisible(true), Guid(IID_IDispatch), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] 301 | private interface ISelection 302 | { 303 | IRange createRange(); 304 | } 305 | 306 | [ComVisible(true), Guid(IID_IDispatch), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] 307 | private interface IRange 308 | { 309 | string text { get; set; } 310 | void collapse(bool start); 311 | void select(); 312 | } 313 | 314 | #pragma warning restore CS0618 // Type or member is obsolete 315 | #endregion 316 | } 317 | } 318 | --------------------------------------------------------------------------------