From 6a39b9431230be7583cdb2cd88c3ea0f6642bb80 Mon Sep 17 00:00:00 2001 From: Marco van den Oever Date: Sun, 17 Mar 2024 23:57:57 +0100 Subject: [PATCH] Workspace --- .../Wtq.Core/Configuration/AttachMode.cs | 32 ++ .../Configuration/CreateProcessOptions.cs | 10 - .../Configuration/FindProcessOptions.cs | 23 - .../Wtq.Core/Configuration/HotkeyOptions.cs | 2 +- .../Wtq.Core/Configuration/WtqAppOptions.cs | 83 +++- .../Wtq.Core/Configuration/WtqOptions.cs | 4 +- src/10-Core/Wtq.Core/Data/HotkeyInfo.cs | 2 +- src/10-Core/Wtq.Core/Data/WtqActionType.cs | 4 +- src/10-Core/Wtq.Core/Data/WtqRect.cs | 2 + .../Wtq.Core/Events/WtqHotkeyPressedEvent.cs | 2 +- .../Wtq.Core/Events/WtqRegisterHotkeyEvent.cs | 2 +- src/10-Core/Wtq.Core/GlobalUsings.cs | 2 + .../Wtq.Core/Services/IWtqAppFactory.cs | 8 + src/10-Core/Wtq.Core/Services/IWtqAppRepo.cs | 2 +- src/10-Core/Wtq.Core/Services/IWtqBus.cs | 3 + .../Wtq.Core/Services/IWtqHotkeyService.cs | 4 +- .../Wtq.Core/Services/IWtqProcessFactory.cs | 128 +++++ .../Wtq.Core/Services/IWtqProcessService.cs | 7 +- src/10-Core/Wtq.Core/Services/WtqApp.cs | 139 ++++-- ...{WtqProcessFactory.cs => WtqAppFactory.cs} | 23 +- .../Wtq.Core/Services/WtqAppMonitorService.cs | 11 +- src/10-Core/Wtq.Core/Services/WtqAppRepo.cs | 6 +- .../Wtq.Core/Services/WtqAppToggleService.cs | 6 +- src/10-Core/Wtq.Core/Services/WtqBus.cs | 6 + .../Wtq.Core/Services/WtqHotkeyService.cs | 23 +- src/10-Core/Wtq.Core/Utils/Retry.cs | 21 +- src/10-Core/Wtq.Core/WtqService.cs | 40 +- .../ServiceCollectionExtensions.cs | 4 +- .../SharpHookGlobalHotkeyService.cs | 20 +- src/10-Core/Wtq.Win32/Win32ProcessService.cs | 69 ++- .../Wtq.WinForms/Native/HotKeyEventArgs.cs | 4 +- .../Wtq.WinForms/Native/HotkeyManager.cs | 10 +- .../ServiceCollectionExtensions.cs | 4 +- src/10-Core/Wtq.WinForms/TrayIcon.cs | 110 +++-- .../Wtq.WinForms/WinFormsHotkeyService.cs | 18 +- src/20-Host/Wtq.Windows/Program.cs | 9 +- src/wtq.jsonc | 36 +- src/wtq.schema.2.json | 460 ++++-------------- 38 files changed, 713 insertions(+), 626 deletions(-) create mode 100644 src/10-Core/Wtq.Core/Configuration/AttachMode.cs delete mode 100644 src/10-Core/Wtq.Core/Configuration/CreateProcessOptions.cs delete mode 100644 src/10-Core/Wtq.Core/Configuration/FindProcessOptions.cs create mode 100644 src/10-Core/Wtq.Core/Services/IWtqAppFactory.cs create mode 100644 src/10-Core/Wtq.Core/Services/IWtqProcessFactory.cs rename src/10-Core/Wtq.Core/Services/{WtqProcessFactory.cs => WtqAppFactory.cs} (59%) diff --git a/src/10-Core/Wtq.Core/Configuration/AttachMode.cs b/src/10-Core/Wtq.Core/Configuration/AttachMode.cs new file mode 100644 index 00000000..3a9e3141 --- /dev/null +++ b/src/10-Core/Wtq.Core/Configuration/AttachMode.cs @@ -0,0 +1,32 @@ +namespace Wtq.Core.Configuration; + +/// +/// How WTQ should try to attach to a process. +/// +public enum AttachMode +{ + /// + /// Used to detect serialization issues. + /// + None = 0, + + /// + /// Always create a new process. + /// + Start, + + /// + /// Only look for existing process. + /// + Find, + + /// + /// Look for existing process, create one of one does not exist yet. + /// + FindOrStart, + + /// + /// Attach to the foreground process when pressing the assigned hot key. + /// + Manual, +} \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Configuration/CreateProcessOptions.cs b/src/10-Core/Wtq.Core/Configuration/CreateProcessOptions.cs deleted file mode 100644 index 0bb37094..00000000 --- a/src/10-Core/Wtq.Core/Configuration/CreateProcessOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Wtq.Core.Configuration; - -public class CreateProcessOptions -{ - [NotNull] - [Required] - public string? FileName { get; set; } - - public IEnumerable Arguments { get; set; } = []; -} \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Configuration/FindProcessOptions.cs b/src/10-Core/Wtq.Core/Configuration/FindProcessOptions.cs deleted file mode 100644 index b45122b6..00000000 --- a/src/10-Core/Wtq.Core/Configuration/FindProcessOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Wtq.Core.Configuration; - -public class FindProcessOptions -{ - public string? ProcessName { get; set; } - - public bool Filter(Process process) - { - ArgumentNullException.ThrowIfNull(process); - - if (process.MainWindowHandle == nint.Zero) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(ProcessName)) - { - return process.ProcessName.Equals(ProcessName, StringComparison.OrdinalIgnoreCase); - } - - return false; - } -} \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Configuration/HotkeyOptions.cs b/src/10-Core/Wtq.Core/Configuration/HotkeyOptions.cs index 74da48eb..f802907e 100644 --- a/src/10-Core/Wtq.Core/Configuration/HotkeyOptions.cs +++ b/src/10-Core/Wtq.Core/Configuration/HotkeyOptions.cs @@ -2,7 +2,7 @@ namespace Wtq.Core.Configuration; -public class HotkeyOptions +public class HotKeyOptions { public WtqKeys Key { get; set; } diff --git a/src/10-Core/Wtq.Core/Configuration/WtqAppOptions.cs b/src/10-Core/Wtq.Core/Configuration/WtqAppOptions.cs index 776c4178..b45dc9e6 100644 --- a/src/10-Core/Wtq.Core/Configuration/WtqAppOptions.cs +++ b/src/10-Core/Wtq.Core/Configuration/WtqAppOptions.cs @@ -4,18 +4,20 @@ namespace Wtq.Core.Configuration; public class WtqAppOptions { + public AttachMode? AttachMode { get; set; } + // TODO: Use dict key? public string Name { get; set; } - public IEnumerable Hotkeys { get; set; } = Array.Empty(); + public IEnumerable HotKeys { get; set; } = []; [NotNull] [Required] - public FindProcessOptions? FindExisting { get; set; } + public string? FileName { get; set; } - [NotNull] - [Required] - public CreateProcessOptions? StartNew { get; set; } + public string? ProcessName { get; set; } + + public string? Arguments { get; set; } /// /// If "PreferMonitor" is set to "AtIndex", this setting determines what monitor to choose. @@ -30,13 +32,78 @@ public class WtqAppOptions /// public PreferMonitor? PreferMonitor { get; set; } - public bool HasHotkey(WtqKeys key, WtqKeyModifiers modifiers) + public bool Filter(Process process, bool isStartedByWtq) + { + ArgumentNullException.ThrowIfNull(process); + + if (process.MainWindowHandle == nint.Zero) + { + return false; + } + + // TODO: Make some notes about webbrowsers being a pain with starting a new process. + try + { + if (process.MainModule == null) + { + return false; + } + + if (process.MainWindowHandle == nint.Zero) + { + return false; + } + + // TODO: Handle extensions either or not being there. + var fn1 = Path.GetFileNameWithoutExtension(ProcessName ?? FileName); + var fn2 = Path.GetFileNameWithoutExtension(process.MainModule.FileName); + + if (fn2.Contains("windowsterminal", StringComparison.OrdinalIgnoreCase)) + { + var dbg = 2; + } + + if (!fn1.Equals(fn2, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (isStartedByWtq) + { + if (!process.StartInfo.Environment.TryGetValue("WTQ_START", out var val)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(val)) + { + return false; + } + + if (!val.Equals(Name, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + catch + { + // TODO: Remove try/catch, use safe property access methods on "Process" instead. + } + + return false; + } + + public bool HasHotKey(WtqKeys key, WtqKeyModifiers modifiers) { - return Hotkeys.Any(hk => hk.Key == key && hk.Modifiers == modifiers); + return HotKeys.Any(hk => hk.Key == key && hk.Modifiers == modifiers); } public override string ToString() { - return FindExisting?.ProcessName ?? StartNew?.FileName ?? ""; + //return FindExisting?.ProcessName ?? StartNew?.FileName ?? ""; + return Name; } } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Configuration/WtqOptions.cs b/src/10-Core/Wtq.Core/Configuration/WtqOptions.cs index 010b6753..6777e137 100644 --- a/src/10-Core/Wtq.Core/Configuration/WtqOptions.cs +++ b/src/10-Core/Wtq.Core/Configuration/WtqOptions.cs @@ -5,8 +5,10 @@ public sealed class WtqOptions [Required] public IEnumerable Apps { get; set; } = []; + public AttachMode AttachMode { get; set; } = AttachMode.FindOrStart; + [Required] - public IEnumerable Hotkeys { get; set; } = []; + public IEnumerable HotKeys { get; set; } = []; /// /// Gets or sets if "PreferMonitor" is set to "AtIndex", this setting determines what monitor to choose. diff --git a/src/10-Core/Wtq.Core/Data/HotkeyInfo.cs b/src/10-Core/Wtq.Core/Data/HotkeyInfo.cs index 3f43b3bb..1d2ea195 100644 --- a/src/10-Core/Wtq.Core/Data/HotkeyInfo.cs +++ b/src/10-Core/Wtq.Core/Data/HotkeyInfo.cs @@ -1,6 +1,6 @@ namespace Wtq.Core.Data; -public sealed class HotkeyInfo +public sealed class HotKeyInfo { public WtqKeys Key { get; set; } diff --git a/src/10-Core/Wtq.Core/Data/WtqActionType.cs b/src/10-Core/Wtq.Core/Data/WtqActionType.cs index 7b45acaf..da9df43f 100644 --- a/src/10-Core/Wtq.Core/Data/WtqActionType.cs +++ b/src/10-Core/Wtq.Core/Data/WtqActionType.cs @@ -4,7 +4,7 @@ public enum WtqActionType { ToggleApp, - HotkeyPressed, + HotKeyPressed, - RegisterHotkeys, + RegisterHotKeys, } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Data/WtqRect.cs b/src/10-Core/Wtq.Core/Data/WtqRect.cs index f2eab3e0..9dbcb776 100644 --- a/src/10-Core/Wtq.Core/Data/WtqRect.cs +++ b/src/10-Core/Wtq.Core/Data/WtqRect.cs @@ -60,4 +60,6 @@ public override readonly int GetHashCode() { return HashCode.Combine(Height, Width, X, Y); } + + public override readonly string ToString() => $"X:{X}, Y:{Y}, Width:{Width}, Height:{Height}"; } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Events/WtqHotkeyPressedEvent.cs b/src/10-Core/Wtq.Core/Events/WtqHotkeyPressedEvent.cs index 54e5014c..b04f0ce5 100644 --- a/src/10-Core/Wtq.Core/Events/WtqHotkeyPressedEvent.cs +++ b/src/10-Core/Wtq.Core/Events/WtqHotkeyPressedEvent.cs @@ -4,7 +4,7 @@ namespace Wtq.Core.Events; -public sealed class WtqHotkeyPressedEvent : IWtqEvent +public sealed class WtqHotKeyPressedEvent : IWtqEvent { public WtqKeys Key { get; set; } diff --git a/src/10-Core/Wtq.Core/Events/WtqRegisterHotkeyEvent.cs b/src/10-Core/Wtq.Core/Events/WtqRegisterHotkeyEvent.cs index 1a14e618..5b8be927 100644 --- a/src/10-Core/Wtq.Core/Events/WtqRegisterHotkeyEvent.cs +++ b/src/10-Core/Wtq.Core/Events/WtqRegisterHotkeyEvent.cs @@ -4,7 +4,7 @@ namespace Wtq.Core.Events; -public sealed class WtqRegisterHotkeyEvent : IWtqEvent +public sealed class WtqRegisterHotKeyEvent : IWtqEvent { public WtqActionType ActionType { get; set; } diff --git a/src/10-Core/Wtq.Core/GlobalUsings.cs b/src/10-Core/Wtq.Core/GlobalUsings.cs index 3f8f3579..c4b509b8 100644 --- a/src/10-Core/Wtq.Core/GlobalUsings.cs +++ b/src/10-Core/Wtq.Core/GlobalUsings.cs @@ -6,6 +6,8 @@ global using System.ComponentModel.DataAnnotations; global using System.Diagnostics; global using System.Diagnostics.CodeAnalysis; +global using System.IO; global using System.Linq; +global using System.Threading; global using System.Threading.Tasks; global using Wtq.Utils; \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/IWtqAppFactory.cs b/src/10-Core/Wtq.Core/Services/IWtqAppFactory.cs new file mode 100644 index 00000000..117892f7 --- /dev/null +++ b/src/10-Core/Wtq.Core/Services/IWtqAppFactory.cs @@ -0,0 +1,8 @@ +using Wtq.Core.Configuration; + +namespace Wtq.Services; + +public interface IWtqAppFactory +{ + WtqApp Create(WtqAppOptions app); +} \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/IWtqAppRepo.cs b/src/10-Core/Wtq.Core/Services/IWtqAppRepo.cs index 9373a3a3..502bc27b 100644 --- a/src/10-Core/Wtq.Core/Services/IWtqAppRepo.cs +++ b/src/10-Core/Wtq.Core/Services/IWtqAppRepo.cs @@ -3,7 +3,7 @@ namespace Wtq.Core.Services; -public interface IWtqAppRepo : IDisposable +public interface IWtqAppRepo : IAsyncDisposable { IReadOnlyCollection Apps { get; } diff --git a/src/10-Core/Wtq.Core/Services/IWtqBus.cs b/src/10-Core/Wtq.Core/Services/IWtqBus.cs index 5f1e23c4..6baa68dc 100644 --- a/src/10-Core/Wtq.Core/Services/IWtqBus.cs +++ b/src/10-Core/Wtq.Core/Services/IWtqBus.cs @@ -4,5 +4,8 @@ public interface IWtqBus { void On(Func predicate, Func onEvent); + void On(Func onEvent) + where TEvent : IWtqEvent; + void Publish(IWtqEvent eventType); } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/IWtqHotkeyService.cs b/src/10-Core/Wtq.Core/Services/IWtqHotkeyService.cs index 2b6720d2..46372a6d 100644 --- a/src/10-Core/Wtq.Core/Services/IWtqHotkeyService.cs +++ b/src/10-Core/Wtq.Core/Services/IWtqHotkeyService.cs @@ -1,6 +1,6 @@ namespace Wtq.Core.Service; -public interface IWtqHotkeyService +public interface IWtqHotKeyService { - // void OnHotkey(Func onHotkey); + // void OnHotKey(Func onHotKey); } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/IWtqProcessFactory.cs b/src/10-Core/Wtq.Core/Services/IWtqProcessFactory.cs new file mode 100644 index 00000000..181bcf0d --- /dev/null +++ b/src/10-Core/Wtq.Core/Services/IWtqProcessFactory.cs @@ -0,0 +1,128 @@ +using System.ComponentModel; +using Wtq.Core.Configuration; +using Wtq.Core.Exceptions; + +namespace Wtq.Core.Services; + +public interface IWtqProcessFactory +{ + Task GetProcessAsync(WtqAppOptions opts); +} + +/// +/// TODO: Move this stuff to WtqApp and IWtqProcessService? +/// +public sealed class WtqProcessFactory : IWtqProcessFactory +{ + private readonly ILogger _log = Log.For(); + + private readonly IOptions _opts; + private readonly IWtqProcessService _procService; + + public WtqProcessFactory( + IOptions opts, + IWtqProcessService procService) + { + _opts = Guard.Against.Null(opts, nameof(opts)); + _procService = Guard.Against.Null(procService, nameof(procService)); + } + + public async Task GetProcessAsync(WtqAppOptions opts) + { + Guard.Against.Null(opts); + + var procs = _procService.GetProcesses(); + + switch (opts.AttachMode ?? _opts.Value.AttachMode) + { + case AttachMode.Start: + return procs + //.Where(p => opts.FindExisting.Filter(p)) + //.Where(p => p.StartInfo.Environment.ContainsKey("__WTQ") && p.StartInfo.E) + .FirstOrDefault(p => opts.Filter(p, true)) + ?? await CreateProcessAsync(opts).ConfigureAwait(false); + + case AttachMode.Manual: + return null; + + case AttachMode.Find: + return procs + .FirstOrDefault(p => opts.Filter(p, false)); + + default: + case AttachMode.FindOrStart: + return procs + .FirstOrDefault(p => opts.Filter(p, false)) + ?? await CreateProcessAsync(opts).ConfigureAwait(false); + } + } + + private async Task CreateProcessAsync(WtqAppOptions opts) + { + _log.LogInformation("Creating process for app '{App}'", opts); + + var process = new Process() + { + StartInfo = new ProcessStartInfo() + { + FileName = opts.FileName, + Arguments = opts.Arguments, + UseShellExecute = false, + Environment = + { + { "WTQ_START", opts.Name }, + }, + }, + }; + + // Start + await Retry.Default + .ExecuteAsync(async () => + { + try + { + process.Start(); + process.Refresh(); + } + catch (Win32Exception ex) when (ex.Message == "The system cannot find the file specified") + { + throw new CancelRetryException($"Could not start process using file name '{opts.FileName}'. Make sure it exists and the configuration is correct."); + } + catch (Exception ex) + { + _log.LogError(ex, "Error starting process: {Message}", ex.Message); + throw; + } + + return 0; + }) + .ConfigureAwait(false); + + // Wait for main window handle to become available. + await Retry.Default + .ExecuteAsync(async () => + { + try + { + _log.LogInformation("Waiting for process input idle"); + process.Refresh(); + //process.WaitForInputIdle(); + + if (process.MainWindowHandle == 0) + { + throw new WtqException("Main window handle not available yet."); + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Error waiting for process input idle: {Message}", ex.Message); + throw; + } + + return 0; + }) + .ConfigureAwait(false); + + return process; + } +} \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/IWtqProcessService.cs b/src/10-Core/Wtq.Core/Services/IWtqProcessService.cs index b9574867..ae1152d7 100644 --- a/src/10-Core/Wtq.Core/Services/IWtqProcessService.cs +++ b/src/10-Core/Wtq.Core/Services/IWtqProcessService.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using Wtq.Core.Data; +using Wtq.Core.Data; namespace Wtq.Core.Services; @@ -7,6 +6,8 @@ public interface IWtqProcessService { void BringToForeground(Process process); + Process? GetForegroundProcess(); + uint GetForegroundProcessId(); WtqRect GetWindowRect(Process process); @@ -20,4 +21,6 @@ public interface IWtqProcessService void SetTransparency(Process process, int transparency); IEnumerable GetProcesses(); + + //string? GetProcessCommandLine(Process process); } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/WtqApp.cs b/src/10-Core/Wtq.Core/Services/WtqApp.cs index a47d422a..ca4cac59 100644 --- a/src/10-Core/Wtq.Core/Services/WtqApp.cs +++ b/src/10-Core/Wtq.Core/Services/WtqApp.cs @@ -4,15 +4,32 @@ namespace Wtq.Services; -public sealed class WtqApp( - IWtqProcessService procService, - IWtqAppToggleService toggler) : IDisposable +/// +/// An "app" represents a single process that can be toggled (such as Windows Terminal).
+/// It tracks its own state, and does not necessarily have a process attached. +///
+public sealed class WtqApp : IAsyncDisposable { private readonly ILogger _log = Log.For(); - private readonly IWtqProcessService _procService = procService ?? throw new ArgumentNullException(nameof(procService)); - private readonly IWtqAppToggleService _toggler = toggler ?? throw new ArgumentNullException(nameof(toggler)); + private readonly IWtqProcessFactory _procFactory; + private readonly IWtqProcessService _procService; + private readonly IWtqAppToggleService _toggler; + + public WtqApp( + IWtqProcessFactory procFactory, + IWtqProcessService procService, + IWtqAppToggleService toggler, + IWtqBus bus, + WtqAppOptions opts) + { + _procFactory = procFactory ?? throw new ArgumentNullException(nameof(procFactory)); + _procService = procService ?? throw new ArgumentNullException(nameof(procService)); + _toggler = toggler ?? throw new ArgumentNullException(nameof(toggler)); + + App = opts ?? throw new ArgumentNullException(nameof(opts)); + } - public WtqAppOptions App { get; set; } + public WtqAppOptions App { get; } /// /// Whether an active process is being tracked by this app instance. @@ -21,20 +38,12 @@ public sealed class WtqApp( public Process? Process { get; set; } - public string? ProcessDescription - { - get - { - if (Process == null) - { - return ""; - } - - return $"[{Process?.Id}] {Process?.ProcessName}"; - } - } + public string? ProcessDescription => Process == null + ? "" + : $"[{Process.Id}] {Process.ProcessName}"; - public static int GetTimeMs(ToggleModifiers mods) + // TODO: Pull from options. + private static int GetTimeMs(ToggleModifiers mods) { switch (mods) { @@ -50,8 +59,16 @@ public static int GetTimeMs(ToggleModifiers mods) } } + /// + /// Puts the window associated with the process on top of everything and gives it focus. + /// public void BringToForeground() { + if (Process == null) + { + throw new InvalidOperationException($"App '{this}' does not have a process attached."); + } + _procService.BringToForeground(Process); } @@ -64,51 +81,88 @@ public async Task CloseAsync(ToggleModifiers mods = ToggleModifiers.None) await _toggler.ToggleAsync(this, false, ms).ConfigureAwait(false); } - public void Dispose() + public async ValueTask DisposeAsync() { + // TODO: Add ability to close attached processes when app closes. if (Process != null) { var bounds = _procService.GetWindowRect(Process); // TODO: Restore to original position (when we got a hold of the process). bounds.Width = 1280; bounds.Height = 800; - bounds.X = 0; - bounds.Y = 0; + bounds.X = 10; + bounds.Y = 10; _log.LogInformation("Restoring process '{Process}' to its original bounds of '{Bounds}'", ProcessDescription, bounds); - _procService.MoveWindow(Process, bounds); + //_procService.MoveWindow(Process, bounds); + + await OpenAsync(ToggleModifiers.Instant).ConfigureAwait(false); + _procService.SetTaskbarIconVisibility(Process, true); } } - public WtqRect GetWindowRect() + public WtqRect? GetWindowRect() { + if (Process == null) + { + throw new InvalidOperationException($"App '{this}' does not have a process attached."); + } + return _procService.GetWindowRect(Process); } public void MoveWindow(WtqRect rect) { + if (Process == null) + { + throw new InvalidOperationException($"App '{this}' does not have a process attached."); + } + _procService.MoveWindow(Process, rect: rect); } public async Task OpenAsync(ToggleModifiers mods = ToggleModifiers.None) { - var ms = GetTimeMs(mods); + // If we have an active process attached, toggle it open. + if (IsActive) + { + var ms = GetTimeMs(mods); + + _log.LogInformation("Opening app '{App}' in {Time}ms", this, ms); - _log.LogInformation("Opening app '{App}' in {Time}ms", this, ms); + await _toggler.ToggleAsync(this, true, ms).ConfigureAwait(false); + } + + if (!IsActive && App.AttachMode == AttachMode.Manual) + { + var pr = _procService.GetForegroundProcess(); + if (pr != null) + { + await AttachAsync(pr).ConfigureAwait(false); + } - await _toggler.ToggleAsync(this, true, ms).ConfigureAwait(false); + _log.LogWarning("ATTACH?!"); + } } public override string ToString() { - return $"[App:{App}] [ProcessID:{Process?.Id}] {Process?.ProcessName ?? ""}"; + try + { + // TODO: Make extensions to safely pull process info without crashing. + return $"[App:{App}] [ProcessID:{Process?.Id}] {Process?.ProcessName ?? ""}"; + } + catch (Exception ex) + { + return $"[App:{App}] "; + } } /// /// Updates the state of the object to reflect running processes on the system. /// - public Task UpdateAsync(IEnumerable processes) + public async Task UpdateAsync() { // Check that if we have a process handle, the process is still active. if (Process?.HasExited ?? false) @@ -119,24 +173,27 @@ public Task UpdateAsync(IEnumerable processes) if (Process == null) { - // TODO: Handle multiple processes coming back? - Process = processes - .FirstOrDefault(App.FindExisting.Filter); + var process = await _procFactory.GetProcessAsync(App).ConfigureAwait(false); - if (Process == null) + if (process == null) { _log.LogWarning("No process instances found for app '{App}'", App); - - // TODO: Create process. - return Task.CompletedTask; + return; } - // TODO: Configurable. - _procService.SetTaskbarIconVisibility(Process, false); - - _log.LogInformation("Found process instance for app '{App}' with name '{ProcessName}' and Id '{ProcessId}'", App, Process.ProcessName, Process.Id); + await AttachAsync(process).ConfigureAwait(false); } + } + + public async Task AttachAsync(Process process) + { + Process = process; + + // TODO: Configurable. + _procService.SetTaskbarIconVisibility(process, false); + + await CloseAsync(ToggleModifiers.Instant).ConfigureAwait(false); - return Task.CompletedTask; + _log.LogInformation("Found process instance for app '{App}' with name '{ProcessName}' and Id '{ProcessId}'", App, process.ProcessName, process.Id); } } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/WtqProcessFactory.cs b/src/10-Core/Wtq.Core/Services/WtqAppFactory.cs similarity index 59% rename from src/10-Core/Wtq.Core/Services/WtqProcessFactory.cs rename to src/10-Core/Wtq.Core/Services/WtqAppFactory.cs index 306305fe..d98ddde3 100644 --- a/src/10-Core/Wtq.Core/Services/WtqProcessFactory.cs +++ b/src/10-Core/Wtq.Core/Services/WtqAppFactory.cs @@ -3,26 +3,27 @@ namespace Wtq.Services; -public interface IWtqProcessFactory -{ - WtqApp Create(WtqAppOptions app); -} - -public class WtqProcessFactory( +public class WtqAppFactory( IWtqAppToggleService toggleService, + IWtqBus bus, + IWtqProcessFactory _procFactory, IWtqProcessService procService) - : IWtqProcessFactory + : IWtqAppFactory { private readonly IWtqAppToggleService _toggleService = toggleService ?? throw new ArgumentNullException(nameof(toggleService)); + private readonly IWtqBus _bus = bus; + private readonly IWtqProcessFactory procFactory = _procFactory ?? throw new ArgumentNullException(nameof(procFactory)); private readonly IWtqProcessService _procService = procService ?? throw new ArgumentNullException(nameof(procService)); public WtqApp Create(WtqAppOptions app) { ArgumentNullException.ThrowIfNull(app); - return new WtqApp(_procService, _toggleService) - { - App = app, - }; + return new WtqApp( + _procFactory, + _procService, + _toggleService, + _bus, + app); } } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/WtqAppMonitorService.cs b/src/10-Core/Wtq.Core/Services/WtqAppMonitorService.cs index d9c1a03b..fedd080a 100644 --- a/src/10-Core/Wtq.Core/Services/WtqAppMonitorService.cs +++ b/src/10-Core/Wtq.Core/Services/WtqAppMonitorService.cs @@ -1,7 +1,4 @@ using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using System.Diagnostics; -using System.Threading; using Wtq.Core.Configuration; using Wtq.Core.Services; @@ -12,7 +9,7 @@ public class WtqAppMonitorService { private readonly ILogger _log = Log.For(); private readonly IOptions _opts; - private readonly IWtqProcessFactory _wtqProcFactory; + private readonly IWtqAppFactory _wtqProcFactory; private readonly IWtqProcessService _procService; private readonly IWtqFocusTracker _focusTracker; private readonly IWtqBus _bus; @@ -30,7 +27,7 @@ public WtqAppMonitorService( IWtqBus bus, IWtqFocusTracker focusTracker, IWtqProcessService procService, - IWtqProcessFactory wtqProcFactory) + IWtqAppFactory wtqProcFactory) { _apps = appRepo; _bus = bus ?? throw new ArgumentNullException(nameof(bus)); @@ -91,11 +88,11 @@ public Task StopAsync(CancellationToken cancellationToken) private async Task UpdateAppProcessesAsync() { // TODO: Handle modifications to apps on runtime (or just request restart?). - var processes = _procService.GetProcesses().OrderBy(p => p.ProcessName).ToList(); + //var processes = _procService.GetProcesses().OrderBy(p => p.ProcessName).ToList(); foreach (var app in _apps.Apps) { - await app.UpdateAsync(processes).ConfigureAwait(false); + await app.UpdateAsync().ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/WtqAppRepo.cs b/src/10-Core/Wtq.Core/Services/WtqAppRepo.cs index 0d69b6aa..e2ad0215 100644 --- a/src/10-Core/Wtq.Core/Services/WtqAppRepo.cs +++ b/src/10-Core/Wtq.Core/Services/WtqAppRepo.cs @@ -9,7 +9,7 @@ public sealed class WtqAppRepo : IWtqAppRepo public WtqAppRepo( IOptionsMonitor opts, - IWtqProcessFactory appFactory) + IWtqAppFactory appFactory) { Guard.Against.Null(opts); Guard.Against.Null(appFactory); @@ -26,11 +26,11 @@ public WtqAppRepo( return _apps.Find(a => a.App.Name == app.Name); } - public void Dispose() + public async ValueTask DisposeAsync() { foreach (var app in Apps) { - app.Dispose(); + await app.DisposeAsync(); } } } \ No newline at end of file diff --git a/src/10-Core/Wtq.Core/Services/WtqAppToggleService.cs b/src/10-Core/Wtq.Core/Services/WtqAppToggleService.cs index 572cb628..79f3fcb8 100644 --- a/src/10-Core/Wtq.Core/Services/WtqAppToggleService.cs +++ b/src/10-Core/Wtq.Core/Services/WtqAppToggleService.cs @@ -74,7 +74,7 @@ public async Task ToggleAsync(WtqApp app, bool open, int durationMs) ? deltaMs / durationMs : 1.0 - (deltaMs / durationMs); - var wndRect = app.GetWindowRect(); + var wndRect = app.GetWindowRect().Value; var intermediateBounds = _termBoundsProvider.GetTerminalBounds(open, screen, wndRect, animationFn(linearProgress)); app.MoveWindow(intermediateBounds); @@ -88,7 +88,7 @@ public async Task ToggleAsync(WtqApp app, bool open, int durationMs) stopwatch.Stop(); // To ensure we end up in exactly the correct final position - var wndRect2 = app.GetWindowRect(); + var wndRect2 = app.GetWindowRect().Value; var finalBounds = _termBoundsProvider.GetTerminalBounds(open, screen, wndRect2, open ? 1.0 : 0.0); app.MoveWindow(rect: finalBounds); @@ -102,6 +102,8 @@ public async Task ToggleAsync(WtqApp app, bool open, int durationMs) // { // Process.SetWindowState(WindowShowStyle.Maximize); // } + + app.BringToForeground(); } else { diff --git a/src/10-Core/Wtq.Core/Services/WtqBus.cs b/src/10-Core/Wtq.Core/Services/WtqBus.cs index d6de4efe..1a8615aa 100644 --- a/src/10-Core/Wtq.Core/Services/WtqBus.cs +++ b/src/10-Core/Wtq.Core/Services/WtqBus.cs @@ -14,6 +14,12 @@ public void On(Func predicate, Func onEvent) }); } + public void On(Func onEvent) + where TEvent : IWtqEvent + { + On(ev => ev is TEvent, ev => onEvent((TEvent)ev)); + } + public void Publish(IWtqEvent ev) { _log.LogInformation("Publishing event '[{Type}] {Event}'", ev.GetType().FullName, ev); diff --git a/src/10-Core/Wtq.Core/Services/WtqHotkeyService.cs b/src/10-Core/Wtq.Core/Services/WtqHotkeyService.cs index 1acdb8e0..fff1ef97 100644 --- a/src/10-Core/Wtq.Core/Services/WtqHotkeyService.cs +++ b/src/10-Core/Wtq.Core/Services/WtqHotkeyService.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Hosting; -using System.Threading; using Wtq.Core.Configuration; using Wtq.Core.Data; using Wtq.Core.Events; @@ -8,13 +7,13 @@ namespace Wtq.Core.Service; -public class WtqHotkeyService : IHostedService, IWtqHotkeyService +public class WtqHotKeyService : IHostedService, IWtqHotKeyService { private readonly IOptionsMonitor _opts; private readonly IWtqAppRepo _appRepo; private readonly IWtqBus _bus; - public WtqHotkeyService( + public WtqHotKeyService( IOptionsMonitor opts, IWtqAppRepo appRepo, IWtqBus bus) @@ -26,15 +25,15 @@ public WtqHotkeyService( _opts.OnChange(SendRegisterEvents); _bus.On( - e => e is WtqHotkeyPressedEvent, + e => e is WtqHotKeyPressedEvent, e => { - var x = (WtqHotkeyPressedEvent)e; + var x = (WtqHotKeyPressedEvent)e; _bus.Publish(new WtqToggleAppEvent() { ActionType = WtqActionType.ToggleApp, - App = GetAppForHotkey(x.Modifiers, x.Key), + App = GetAppForHotKey(x.Modifiers, x.Key), }); return Task.CompletedTask; @@ -46,9 +45,9 @@ private void SendRegisterEvents(WtqOptions opts) // _log foreach (var app in opts.Apps) { - foreach (var hk in app.Hotkeys) + foreach (var hk in app.HotKeys) { - _bus.Publish(new WtqRegisterHotkeyEvent() + _bus.Publish(new WtqRegisterHotKeyEvent() { Key = hk.Key, Modifiers = hk.Modifiers, @@ -56,9 +55,9 @@ private void SendRegisterEvents(WtqOptions opts) } } - foreach (var hk in opts.Hotkeys) + foreach (var hk in opts.HotKeys) { - _bus.Publish(new WtqRegisterHotkeyEvent() + _bus.Publish(new WtqRegisterHotKeyEvent() { Key = hk.Key, Modifiers = hk.Modifiers, @@ -66,9 +65,9 @@ private void SendRegisterEvents(WtqOptions opts) } } - public WtqApp? GetAppForHotkey(WtqKeyModifiers keyMods, WtqKeys key) + public WtqApp? GetAppForHotKey(WtqKeyModifiers keyMods, WtqKeys key) { - var opt = _opts.CurrentValue.Apps.FirstOrDefault(app => app.HasHotkey(key, keyMods)); + var opt = _opts.CurrentValue.Apps.FirstOrDefault(app => app.HasHotKey(key, keyMods)); if (opt == null) { return null; diff --git a/src/10-Core/Wtq.Core/Utils/Retry.cs b/src/10-Core/Wtq.Core/Utils/Retry.cs index 119ae88a..954eb19c 100644 --- a/src/10-Core/Wtq.Core/Utils/Retry.cs +++ b/src/10-Core/Wtq.Core/Utils/Retry.cs @@ -1,4 +1,6 @@ -namespace Wtq.Utils; +using Wtq.Core.Exceptions; + +namespace Wtq.Utils; public class Retry : IRetry { @@ -29,7 +31,9 @@ public TResult Execute(Func action) public async Task ExecuteAsync(Func> action) { - var maxAttempts = 10; + Guard.Against.Null(action, nameof(action)); + + var maxAttempts = 5; var curAttempt = 0; while (true) @@ -40,12 +44,11 @@ public async Task ExecuteAsync(Func> action) { return await action().ConfigureAwait(false); } - - // catch (CancelRetryException ex) - // { - // _log.LogWarning(ex, "Cancelling retry"); - // throw; - // } + catch (CancelRetryException ex) + { + _log.LogWarning(ex, "Cancelling retry"); + throw; + } catch (Exception ex) { _log.LogWarning(ex, "[Attempt {CurrentAttempt}/{MaxAttempts}] Got exception {Message}", curAttempt, maxAttempts, ex.Message); @@ -55,7 +58,7 @@ public async Task ExecuteAsync(Func> action) throw; } - var wait = TimeSpan.FromSeconds(2); + var wait = TimeSpan.FromMilliseconds(500); _log.LogInformation("Waiting '{Delay}' before next attempt", wait); await Task.Delay(wait).ConfigureAwait(false); } diff --git a/src/10-Core/Wtq.Core/WtqService.cs b/src/10-Core/Wtq.Core/WtqService.cs index a7cad1b1..4662400d 100644 --- a/src/10-Core/Wtq.Core/WtqService.cs +++ b/src/10-Core/Wtq.Core/WtqService.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Hosting; -using System.Threading; using Wtq.Core.Configuration; using Wtq.Core.Data; using Wtq.Core.Events; @@ -29,20 +28,8 @@ public Task StartAsync(CancellationToken cancellationToken) { _log.LogInformation("Starting"); - _bus.On(e => e is WtqToggleAppEvent, ToggleStuffAsync); - - _bus.On( - e => e is WtqAppFocusEvent, - async e => - { - var ev = (WtqAppFocusEvent)e; - - if (ev.App != null && ev.App == open && !ev.GainedFocus) - { - await open.CloseAsync().ConfigureAwait(false); - open = null; - } - }); + _bus.On(HandleToggleAppEvent); + _bus.On(HandleAppFocusEventAsync); return Task.CompletedTask; } @@ -54,7 +41,16 @@ public Task StopAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - private async Task ToggleStuffAsync(IWtqEvent ev) + private async Task HandleAppFocusEventAsync(WtqAppFocusEvent ev) + { + if (ev.App != null && ev.App == open && !ev.GainedFocus) + { + await open.CloseAsync().ConfigureAwait(false); + open = null; + } + } + + private async Task HandleToggleAppEvent(WtqToggleAppEvent ev) { var app = ev.App; @@ -93,12 +89,12 @@ private async Task ToggleStuffAsync(IWtqEvent ev) return; } - // We can't toggle apps that are not active. - if (!app.IsActive) - { - _log.LogWarning("WTQ process for app '{App}' does not have a process instance assigned", app); - return; - } + //// We can't toggle apps that are not active. + //if (!app.IsActive) + //{ + // _log.LogWarning("WTQ process for app '{App}' does not have a process instance assigned", app); + // return; + //} if (open != null) { diff --git a/src/10-Core/Wtq.SharpHook/ServiceCollectionExtensions.cs b/src/10-Core/Wtq.SharpHook/ServiceCollectionExtensions.cs index 61bf5581..66a6ac51 100644 --- a/src/10-Core/Wtq.SharpHook/ServiceCollectionExtensions.cs +++ b/src/10-Core/Wtq.SharpHook/ServiceCollectionExtensions.cs @@ -4,9 +4,9 @@ namespace Wtq.SharpHook; public static class ServiceCollectionExtensions { - public static IServiceCollection AddSharpHookGlobalHotkeys(this IServiceCollection services) + public static IServiceCollection AddSharpHookGlobalHotKeys(this IServiceCollection services) { return services - .AddHostedService(); + .AddHostedService(); } } \ No newline at end of file diff --git a/src/10-Core/Wtq.SharpHook/SharpHookGlobalHotkeyService.cs b/src/10-Core/Wtq.SharpHook/SharpHookGlobalHotkeyService.cs index 7ecd5479..48ed7971 100644 --- a/src/10-Core/Wtq.SharpHook/SharpHookGlobalHotkeyService.cs +++ b/src/10-Core/Wtq.SharpHook/SharpHookGlobalHotkeyService.cs @@ -14,7 +14,7 @@ namespace Wtq.SharpHook; -public sealed class SharpHookGlobalHotkeyService : IDisposable, IHostedService +public sealed class SharpHookGlobalHotKeyService : IDisposable, IHostedService { private readonly IGlobalHook _hook; private readonly IWtqBus _bus; @@ -22,9 +22,9 @@ public sealed class SharpHookGlobalHotkeyService : IDisposable, IHostedService private readonly IWtqAppRepo _appRepo; private UioHookEvent? _last; - // TODO: Make specific to hotkey combinations. - // private List> _registrations = []; - public SharpHookGlobalHotkeyService( + // TODO: Make specific to HotKey combinations. + // private List> _registrations = []; + public SharpHookGlobalHotKeyService( IOptionsMonitor opts, IWtqBus bus, IWtqAppRepo appRepo) @@ -44,21 +44,21 @@ public SharpHookGlobalHotkeyService( return; } - var app = GetAppForHotkey(a.RawEvent.Mask.ToWtqKeyModifiers(), a.Data.KeyCode.ToWtqKeys()); + var app = GetAppForHotKey(a.RawEvent.Mask.ToWtqKeyModifiers(), a.Data.KeyCode.ToWtqKeys()); if (a.RawEvent.Mask == ModifierMask.LeftCtrl && a.Data.KeyCode == KeyCode.Vc2) { a.SuppressEvent = true; Console.WriteLine("SUPPRESS"); - // TODO: Put something in between ingesting hotkeys and publishing functional events. + // TODO: Put something in between ingesting HotKeys and publishing functional events. _bus.Publish(new WtqEvent() { ActionType = WtqActionType.ToggleApp, App = app, }); - // var inf = new HotkeyInfo() + // var inf = new HotKeyInfo() // { // Key = WtqKeys.A, // Modifiers = WtqKeyModifiers.Alt, @@ -86,9 +86,9 @@ public void Dispose() _hook.Dispose(); } - public WtqApp? GetAppForHotkey(WtqKeyModifiers keyMods, WtqKeys key) + public WtqApp? GetAppForHotKey(WtqKeyModifiers keyMods, WtqKeys key) { - var opt = _opts.CurrentValue.Apps.FirstOrDefault(app => app.HasHotkey(key, keyMods)); + var opt = _opts.CurrentValue.Apps.FirstOrDefault(app => app.HasHotKey(key, keyMods)); if (opt == null) { return null; @@ -97,7 +97,7 @@ public void Dispose() return _appRepo.GetProcessForApp(opt); } - public void OnHotkey(Func onHotkey) + public void OnHotKey(Func onHotKey) { // Method intentionally left empty. } diff --git a/src/10-Core/Wtq.Win32/Win32ProcessService.cs b/src/10-Core/Wtq.Win32/Win32ProcessService.cs index 3c95fe2a..c6d3b53f 100644 --- a/src/10-Core/Wtq.Win32/Win32ProcessService.cs +++ b/src/10-Core/Wtq.Win32/Win32ProcessService.cs @@ -1,7 +1,9 @@ -using System.Diagnostics; +using Microsoft.Extensions.Logging; +using System.Diagnostics; using Wtq.Core.Data; using Wtq.Core.Exceptions; using Wtq.Core.Services; +using Wtq.Utils; using Wtq.Win32.Native; namespace Wtq.Win32; @@ -19,6 +21,25 @@ public void BringToForeground(Process process) //SetWindowState(WindowShowStyle.Restore); User32.SetForegroundWindow(process.MainWindowHandle); User32.ForcePaint(process.MainWindowHandle); + //User32.ShowWindow(process.MainWindowHandle, WindowShowStyle.Show); + //User32.SendMessage(process.MainWindowHandle, ) + } + + public Process? GetForegroundProcess() + { + try + { + var fg = GetForegroundProcessId(); + if (fg > 0) + { + return Process.GetProcessById((int)fg); + } + } + catch (Exception ex) + { + } + + return null; } public uint GetForegroundProcessId() @@ -32,9 +53,25 @@ public uint GetForegroundProcessId() return pid; } + private readonly object _procLock = new(); + private IEnumerable _processes = []; + private DateTimeOffset _nextLookup = DateTimeOffset.MinValue; + private TimeSpan _lookupInterval = TimeSpan.FromSeconds(2); + private readonly ILogger _log = Log.For(); + public IEnumerable GetProcesses() { - return Process.GetProcesses(); + lock (_procLock) + { + if (_nextLookup < DateTimeOffset.UtcNow) + { + _log.LogDebug("Looking up list of processes"); + _nextLookup = DateTimeOffset.UtcNow.Add(_lookupInterval); + _processes = Process.GetProcesses(); + } + } + + return _processes; } public WtqRect GetWindowRect(Process process) @@ -88,6 +125,8 @@ public void SetTaskbarIconVisibility(Process process, bool isVisible) // Get handle to the main window var handle = process.MainWindowHandle; + _log.LogInformation("Setting taskbar icon visibility for process with main window handle '{Handle}'", handle); + // Get current window properties var props = User32.GetWindowLong(handle, User32.GWLEXSTYLE); @@ -133,4 +172,30 @@ public void SetTransparency(Process process, int transparency) // User32.ShowWindow(process.MainWindowHandle, state); //} + + //private static int GetParentProcess(int Id) + //{ + // int parentPid = 0; + // using ManagementObject mo = new ManagementObject("win32_process.handle='" + Id.ToString() + "'"); + + // mo.Get(); + // parentPid = Convert.ToInt32(mo["ParentProcessId"]); + + // return parentPid; + //} + + //public string? GetProcessCommandLine(Process process) + //{ + // try + // { + // using var searcher = new ManagementObjectSearcher("SELECT CommandLine FROM Win32_Process WHERE ProcessId = " + process.Id); + // using var objects = searcher.Get(); + + // return objects.Cast().SingleOrDefault()?["CommandLine"]?.ToString(); + // } + // catch + // { + // return null; + // } + //} } \ No newline at end of file diff --git a/src/10-Core/Wtq.WinForms/Native/HotKeyEventArgs.cs b/src/10-Core/Wtq.WinForms/Native/HotKeyEventArgs.cs index 59f3bfb1..8d93f8f9 100644 --- a/src/10-Core/Wtq.WinForms/Native/HotKeyEventArgs.cs +++ b/src/10-Core/Wtq.WinForms/Native/HotKeyEventArgs.cs @@ -11,9 +11,9 @@ public HotKeyEventArgs(Keys key, KeyModifiers modifiers) Modifiers = modifiers; } - public HotKeyEventArgs(nint hotKeyParam) + public HotKeyEventArgs(nint HotKeyParam) { - uint param = (uint)hotKeyParam.ToInt64(); + uint param = (uint)HotKeyParam.ToInt64(); Key = (Keys)((param & 0xffff0000) >> 16); Modifiers = (KeyModifiers)(param & 0x0000ffff); } diff --git a/src/10-Core/Wtq.WinForms/Native/HotkeyManager.cs b/src/10-Core/Wtq.WinForms/Native/HotkeyManager.cs index 3a5e8cb2..152445cf 100644 --- a/src/10-Core/Wtq.WinForms/Native/HotkeyManager.cs +++ b/src/10-Core/Wtq.WinForms/Native/HotkeyManager.cs @@ -3,7 +3,7 @@ /// /// Wrapper around Windows Forms global hot key functionality. Surfaces a delegate to handle the hot key being pressed. /// -internal static class HotkeyManager +internal static class HotKeyManager { private static readonly ManualResetEvent _windowReadyEvent = new(false); @@ -13,14 +13,14 @@ internal static class HotkeyManager private static volatile MessageWindow _wnd; - static HotkeyManager() + static HotKeyManager() { Thread messageLoop = new(delegate () { Application.Run(new MessageWindow()); }) { - Name = $"{nameof(Wtq)}.{nameof(WinForms)}.{nameof(HotkeyManager)}", + Name = $"{nameof(Wtq)}.{nameof(WinForms)}.{nameof(HotKeyManager)}", IsBackground = true, }; @@ -55,7 +55,7 @@ public static void UnregisterHotKey(int id) /// private sealed class MessageWindow : Form { - private const int WMHOTKEY = 0x312; + private const int WMHotKey = 0x312; public MessageWindow() { @@ -72,7 +72,7 @@ protected override void SetVisibleCore(bool value) protected override void WndProc(ref Message m) { - if (m.Msg == WMHOTKEY) + if (m.Msg == WMHotKey) { HotKeyEventArgs e = new(m.LParam); HotKeyPressed?.Invoke(null, e); diff --git a/src/10-Core/Wtq.WinForms/ServiceCollectionExtensions.cs b/src/10-Core/Wtq.WinForms/ServiceCollectionExtensions.cs index 077e5ba0..40994a4c 100644 --- a/src/10-Core/Wtq.WinForms/ServiceCollectionExtensions.cs +++ b/src/10-Core/Wtq.WinForms/ServiceCollectionExtensions.cs @@ -6,10 +6,10 @@ namespace Wtq.WinForms; public static class ServiceCollectionExtensions { - public static IServiceCollection AddWinFormsHotkeyService(this IServiceCollection services) + public static IServiceCollection AddWinFormsHotKeyService(this IServiceCollection services) { return services - .AddHostedService(); + .AddHostedService(); } public static IServiceCollection AddWinFormsScreenCoordsProvider(this IServiceCollection services) diff --git a/src/10-Core/Wtq.WinForms/TrayIcon.cs b/src/10-Core/Wtq.WinForms/TrayIcon.cs index 6510430e..ef186db1 100644 --- a/src/10-Core/Wtq.WinForms/TrayIcon.cs +++ b/src/10-Core/Wtq.WinForms/TrayIcon.cs @@ -1,4 +1,5 @@ -using Wtq.Core; +using System.Runtime.InteropServices; +using Wtq.Core; using Wtq.Core.Resources; using Wtq.Win32.Native; @@ -16,17 +17,18 @@ public TrayIcon(Action exitHandler) { var contextMenu = new ContextMenuStrip(); - contextMenu.Items.AddRange(new[] + contextMenu.Items.AddRange(new ToolStripItem[] { - // Version CreateVersionItem(), - // Open settings file + new ToolStripSeparator(), + + CreateOpenWebsiteItem(), + CreateOpenSettingsItem(), CreateConsoleItem(), - // Exit CreateExitItem(exitHandler), }); @@ -49,12 +51,42 @@ public TrayIcon(Action exitHandler) waiter.Task.GetAwaiter().GetResult(); } - private static ToolStripMenuItem CreateVersionItem() + public void Dispose() { - return new ToolStripMenuItem($"Version v2.x.x") - { - Enabled = false, - }; + _notificationIcon?.Dispose(); + _notificationIcon = null; + + Application.Exit(); + } + + private static ToolStripMenuItem CreateConsoleItem() + { + var mnuExit = new ToolStripMenuItem("Pop Console"); + + mnuExit.Click += new EventHandler((s, a) => Kernel32.AllocConsole()); + + return mnuExit; + } + + private static ToolStripMenuItem CreateExitItem(Action exitHandler) + { + var mnuExit = new ToolStripMenuItem("Exit"); + + mnuExit.Click += new EventHandler(exitHandler); + + return mnuExit; + } + + private static Icon CreateIcon() + { + using var str = new MemoryStream(Resources.icon); + return new Icon(str); + + // var bitmap = Resources.icon.ToBitmap(); + // bitmap.MakeTransparent(Color.White); + // var icH = bitmap.GetHicon(); + // var ico = Icon.FromHandle(icH); + // return ico; } private static ToolStripMenuItem CreateOpenSettingsItem() @@ -95,41 +127,45 @@ private static ToolStripMenuItem CreateOpenSettingsItem() return mnuOpenSettings; } - private static ToolStripItem CreateConsoleItem() + private static ToolStripMenuItem CreateOpenWebsiteItem() { - var mnuExit = new ToolStripMenuItem("Pop Console"); - - mnuExit.Click += new EventHandler((s, a) => Kernel32.AllocConsole()); - - return mnuExit; - } - - private static ToolStripItem CreateExitItem(Action exitHandler) - { - var mnuExit = new ToolStripMenuItem("Exit"); + var item = new ToolStripMenuItem($"Open GitHub Project Website") + { + Enabled = true, + }; - mnuExit.Click += new EventHandler(exitHandler); + item.Click += (s, a) => + { + OpenBrowser("https://www.github.com/flyingpie/windows-terminal-quake"); + }; - return mnuExit; + return item; } - public void Dispose() + private static ToolStripMenuItem CreateVersionItem() { - _notificationIcon?.Dispose(); - _notificationIcon = null; - - Application.Exit(); + return new ToolStripMenuItem($"Version v2.x.x") + { + Enabled = false, + }; } - private static Icon CreateIcon() + public static void OpenBrowser(string url) { - using var str = new MemoryStream(Resources.icon); - return new Icon(str); - - // var bitmap = Resources.icon.ToBitmap(); - // bitmap.MakeTransparent(Color.White); - // var icH = bitmap.GetHicon(); - // var ico = Icon.FromHandle(icH); - // return ico; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); // Works ok on windows + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); // Works ok on linux + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); // Not tested + } + else + { + } } } \ No newline at end of file diff --git a/src/10-Core/Wtq.WinForms/WinFormsHotkeyService.cs b/src/10-Core/Wtq.WinForms/WinFormsHotkeyService.cs index 30995420..e4026f6f 100644 --- a/src/10-Core/Wtq.WinForms/WinFormsHotkeyService.cs +++ b/src/10-Core/Wtq.WinForms/WinFormsHotkeyService.cs @@ -7,35 +7,35 @@ namespace Wtq.Win32; -public class WinFormsHotkeyService : IHostedService +public class WinFormsHotKeyService : IHostedService { - private readonly ILogger _log = Log.For(); + private readonly ILogger _log = Log.For(); private readonly IWtqBus _bus; private KeyModifiers? _lastKeyMod; private Keys? _lastKey; - public WinFormsHotkeyService(IWtqBus bus) + public WinFormsHotKeyService(IWtqBus bus) { _bus = bus ?? throw new ArgumentNullException(nameof(bus)); _bus.On( - e => e is WtqRegisterHotkeyEvent, + e => e is WtqRegisterHotKeyEvent, e => { - var x = (WtqRegisterHotkeyEvent)e; + var x = (WtqRegisterHotKeyEvent)e; var mods = (KeyModifiers)x.Modifiers; var key = (Keys)x.Key; - _log.LogInformation("Registering hotkey [{Modifiers}] '{Key}'", mods, key); + _log.LogInformation("Registering HotKey [{Modifiers}] '{Key}'", mods, key); - HotkeyManager.RegisterHotKey(key, mods); + HotKeyManager.RegisterHotKey(key, mods); return Task.CompletedTask; }); - HotkeyManager.HotKeyPressed += (s, a) => + HotKeyManager.HotKeyPressed += (s, a) => { if (_lastKeyMod == a.Modifiers && _lastKey == a.Key) { @@ -46,7 +46,7 @@ public WinFormsHotkeyService(IWtqBus bus) _lastKeyMod = a.Modifiers; _lastKey = a.Key; - _bus.Publish(new WtqHotkeyPressedEvent() + _bus.Publish(new WtqHotKeyPressedEvent() { Key = a.Key.ToWtqKeys(), Modifiers = a.Modifiers.ToWtqKeyModifiers(), diff --git a/src/20-Host/Wtq.Windows/Program.cs b/src/20-Host/Wtq.Windows/Program.cs index 79f1f60d..d8720ea5 100644 --- a/src/20-Host/Wtq.Windows/Program.cs +++ b/src/20-Host/Wtq.Windows/Program.cs @@ -74,24 +74,25 @@ public Program() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddHostedService(p => p.GetRequiredService()) .AddHostedService() .AddSingleton() - .AddHostedService() + .AddHostedService() .AddSingletonHostedService() + .AddSingleton() // Platform-specific. .AddWin32ProcessService() .AddWinFormsScreenCoordsProvider() - .AddWinFormsHotkeyService() + .AddWinFormsHotKeyService() .AddWinFormsTrayIcon() - // .AddSharpHookGlobalHotkeys() + // .AddSharpHookGlobalHotKeys() // .AddSimpleTrayIcon() ; }) diff --git a/src/wtq.jsonc b/src/wtq.jsonc index 9ee00619..7924437b 100644 --- a/src/wtq.jsonc +++ b/src/wtq.jsonc @@ -4,33 +4,25 @@ // TODO: V2 Docs // TODO: Schema "$schema": "./wtq.schema.2.json", + "Apps": [ + // Example with PowerShell { - "Name": "Terminal", - - "HotKeys": [ { "Modifiers": "Control", "Key": "D1" } ], - - "FindExisting": { - "ProcessName": "powershell" - }, - - "StartNew": { - "FileName": "powershell", - "Arguments": "" - }, - - // What monitor to preferably drop the terminal. - // "WithCursor" (default), "Primary" or "AtIndex". - "PreferMonitor": "WithCursor", - - // If "PreferMonitor" is set to "AtIndex", this setting determines what monitor to choose. - // Zero based, eg. 0, 1, etc. - // Defaults to "0". - "MonitorIndex": 0 + "Name": "PowerShell", + "HotKeys": [{ "Modifiers": "Control", "Key": "D1" }], + "FileName": "powershell" } + + // Example with Windows Terminal + //{ + // "Name": "Terminal", + // "HotKeys": [{ "Modifiers": "Control", "Key": "D1" }], + // "FileName": "wt", + // "ProcessName": "WindowsTerminal" + //} ], - // Hotkeys that toggle the most recent app (or the first one if none has been active yet). + // Hot keys that toggle the most recent app (or the first one if none has been active yet). "HotKeys": [ { "Modifiers": "Control", "Key": "Q" } ], // What monitor to preferably drop the terminal. diff --git a/src/wtq.schema.2.json b/src/wtq.schema.2.json index 6073338a..1cc5602c 100644 --- a/src/wtq.schema.2.json +++ b/src/wtq.schema.2.json @@ -3,401 +3,119 @@ "$id": "wtq-schema-2", "type": "object", "title": "WTQ settings schema.", + + "$defs": { + "AttachMode": { + "title": "Attach mode", + "description": "How WTQ attempts to attach to a process to toggle.", + // "enum": [ "FindOrCreate", "Find", "Create" ], + "default": "FindOrStart", + "oneOf": [ + { "const": "FindOrStart", "description": "Looks for an existing process first, and creates one if none was found." }, + { "const": "Find", "description": "Only look for an existing process, don't create a new one." }, + { "const": "Start", "description": "Always create a new process." } + ] + }, + "HotKey": { + "type": "object", + "title": "HotKeys", + "description": "Keyboard combinations for when to toggle the terminal.", + "required": [ "Modifiers", "Key" ], + "properties": { + "Modifiers": { + "$id": "#/properties/HotKeys/items/anyOf/0/properties/Modifiers", + "title": "Modifiers", + "description": "Key modifiers, such as control, shift or alt", + "enum": [ "Alt", "Control", "Shift", "Windows", "NoRepeat" ] + }, + "Key": { + "$id": "#/properties/HotKeys/items/anyOf/0/properties/Key", + "title": "Key", + "description": "Key, such as a letter, a number or a symbol.", + "enum": [ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "D0", "D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20", "F21", "F22", "F23", "F24", "NumPad0", "NumPad1", "NumPad2", "NumPad3", "NumPad4", "NumPad5", "NumPad6", "NumPad7", "NumPad8", "NumPad9", "CapsLock", "Enter", "Escape", "Space", "Tab", "BrowserBack", "BrowserFavorites", "BrowserForward", "BrowserHome", "BrowserRefresh", "BrowserSearch", "BrowserStop", "Oem1", "Oem2", "Oem3", "Oem4", "Oem5", "Oem6", "Oem7", "Oem8", "Oem102", "OemBackslash", "OemClear", "OemCloseBrackets", "Oemcomma", "OemMinus", "OemOpenBrackets", "OemPeriod", "OemPipe", "Oemplus", "OemQuestion", "OemQuotes", "OemSemicolon", "Oemtilde", "Alt", "Control", "ControlKey", "LControlKey", "LMenu", "LShiftKey", "LWin", "RControlKey", "RMenu", "RShiftKey", "RWin", "Shift", "ShiftKey", "Delete", "End", "Home", "Insert", "PageDown", "PageUp", "LButton", "MButton", "RButton", "Down", "Left", "Right", "Up", "Add", "Clear", "Divide", "Multiply", "Subtract", "MediaNextTrack", "MediaPlayPause", "MediaPreviousTrack", "MediaStop", "Apps", "Attn", "Back", "Cancel", "Capital", "Crsel", "Decimal", "EraseEof", "Execute", "Exsel", "FinalMode", "HanguelMode", "HangulMode", "HanjaMode", "Help", "IMEAccept", "IMEAceept", "IMEConvert", "IMEModeChange", "IMENonconvert", "JunjaMode", "KanaMode", "KanjiMode", "KeyCode", "LaunchApplication1", "LaunchApplication2", "LaunchMail", "LineFeed", "Menu", "Modifiers", "Next", "NoName", "None", "NumLock", "Pa1", "Packet", "Pause", "Play", "Print", "PrintScreen", "Prior", "ProcessKey", "Return", "Scroll", "Select", "SelectMedia", "Separator", "Sleep", "Snapshot", "VolumeDown", "VolumeMute", "VolumeUp", "XButton1", "XButton2", "Zoom" ] + } + } + } + }, + "properties": { - "Hotkeys": { - "$id": "#/properties/Hotkeys", - "title": "Hotkeys", + // TODO: LogLevel + // TODO: MaximizeAfterToggle + // TODO: Opacity + // TODO: StartHidden + // TODO: SuppressHotKeyForProcesses + // TODO: TaskbarIconVisibility + // TODO: ToggleAnimationFrameTimeMs + // TODO: ToggleAnimationType + // TODO: ToggleDurationMs + // TODO: ToggleMode + // TODO: VerticalOffset + // TODO: VerticalScreenCoverage + + "Apps": { + "title": "", + "description": "", + "type": "array", + "default": [], + "examples": [], + "items": { + "type": "object", + "title": "", + "description": "", + "required": "", + "properties": { + "AttachMode": { + "$ref": "#/$defs/AttachMode" + }, + "Name": { + + }, + "HotKeys": { + "items": { "$ref": "#/$defs/HotKey" } + }, + "StartNew": { + + }, + "FindExisting": { + + } + } + } + }, + "AttachMode": { + "$ref": "#/$defs/AttachMode" + }, + "HotKeys": { + "title": "HotKeys", "description": "Keys or key combinations which toggle the terminal.", "type": "array", "default": [ - { - "Modifiers": "Control", - "Key": "Oemtilde" - } + { "Modifiers": "Control", "Key": "Oemtilde" } ], "examples": [ [ - { - "Modifiers": "Control", - "Key": "Q" - } + { "Modifiers": "Control", "Key": "Q" } ], [ - { - "Modifiers": "Control", - "Key": "Oemtilde" - }, - { - "Modifiers": "Control", - "Key": "Q" - } + { "Modifiers": "Control", "Key": "Oemtilde" }, + { "Modifiers": "Control", "Key": "Q" } ] ], "items": { - "$id": "#/properties/Hotkeys/items", - "anyOf": [ - { - "$id": "#/properties/Hotkeys/items/anyOf/0", - "type": "object", - "title": "Hotkeys", - "description": "Keyboard combinations for when to toggle the terminal.", - "required": [ - "Modifiers", - "Key" - ], - "properties": { - "Modifiers": { - "$id": "#/properties/Hotkeys/items/anyOf/0/properties/Modifiers", - "title": "Modifiers", - "description": "Key modifiers, such as control, shift or alt", - "enum": [ - "Alt", - "Control", - "Shift", - "Windows", - "NoRepeat" - ] - }, - "Key": { - "$id": "#/properties/Hotkeys/items/anyOf/0/properties/Key", - "title": "Key", - "description": "Key, such as a letter, a number or a symbol.", - "enum": [ - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", - "J", - "K", - "L", - "M", - "N", - "O", - "P", - "Q", - "R", - "S", - "T", - "U", - "V", - "W", - "X", - "Y", - "Z", - "D0", - "D1", - "D2", - "D3", - "D4", - "D5", - "D6", - "D7", - "D8", - "D9", - "F1", - "F2", - "F3", - "F4", - "F5", - "F6", - "F7", - "F8", - "F9", - "F10", - "F11", - "F12", - "F13", - "F14", - "F15", - "F16", - "F17", - "F18", - "F19", - "F20", - "F21", - "F22", - "F23", - "F24", - "NumPad0", - "NumPad1", - "NumPad2", - "NumPad3", - "NumPad4", - "NumPad5", - "NumPad6", - "NumPad7", - "NumPad8", - "NumPad9", - "CapsLock", - "Enter", - "Escape", - "Space", - "Tab", - "BrowserBack", - "BrowserFavorites", - "BrowserForward", - "BrowserHome", - "BrowserRefresh", - "BrowserSearch", - "BrowserStop", - "Oem1", - "Oem2", - "Oem3", - "Oem4", - "Oem5", - "Oem6", - "Oem7", - "Oem8", - "Oem102", - "OemBackslash", - "OemClear", - "OemCloseBrackets", - "Oemcomma", - "OemMinus", - "OemOpenBrackets", - "OemPeriod", - "OemPipe", - "Oemplus", - "OemQuestion", - "OemQuotes", - "OemSemicolon", - "Oemtilde", - "Alt", - "Control", - "ControlKey", - "LControlKey", - "LMenu", - "LShiftKey", - "LWin", - "RControlKey", - "RMenu", - "RShiftKey", - "RWin", - "Shift", - "ShiftKey", - "Delete", - "End", - "Home", - "Insert", - "PageDown", - "PageUp", - "LButton", - "MButton", - "RButton", - "Down", - "Left", - "Right", - "Up", - "Add", - "Clear", - "Divide", - "Multiply", - "Subtract", - "MediaNextTrack", - "MediaPlayPause", - "MediaPreviousTrack", - "MediaStop", - "Apps", - "Attn", - "Back", - "Cancel", - "Capital", - "Crsel", - "Decimal", - "EraseEof", - "Execute", - "Exsel", - "FinalMode", - "HanguelMode", - "HangulMode", - "HanjaMode", - "Help", - "IMEAccept", - "IMEAceept", - "IMEConvert", - "IMEModeChange", - "IMENonconvert", - "JunjaMode", - "KanaMode", - "KanjiMode", - "KeyCode", - "LaunchApplication1", - "LaunchApplication2", - "LaunchMail", - "LineFeed", - "Menu", - "Modifiers", - "Next", - "NoName", - "None", - "NumLock", - "Pa1", - "Packet", - "Pause", - "Play", - "Print", - "PrintScreen", - "Prior", - "ProcessKey", - "Return", - "Scroll", - "Select", - "SelectMedia", - "Separator", - "Sleep", - "Snapshot", - "VolumeDown", - "VolumeMute", - "VolumeUp", - "XButton1", - "XButton2", - "Zoom" - ] - } - } - } - ] + "$ref": "#/$defs/HotKey" } }, - "LogLevel": { - "$id": "#/properties/LogLevel", - "title": "Log level", - "description": "Minimum level of events that are logged.", - "enum": [ - "Verbose", - "Debug", - "Information", - "Warning", - "Error", - "Fatal" - ], - "default": "Error" - }, - "MaximizeAfterToggle": { - "$id": "#/properties/MaximizeAfterToggle", - "title": "Maximize after toggle", - "description": "Whether to maximize the terminal after it has toggled into view.\nNote that this only applies when both HorizontalScreenCoverage and VerticalScreenCoverage are at least 100.", - "type": "boolean", - "default": true - }, "MonitorIndex": { - "$id": "#/properties/MonitorIndex", "title": "Monitor index", "description": "When \"PreferMonitor\" is set to \"AtIndex\", this setting controls what monitor to toggle on. 0-based, eg. \"0\", \"1\", etc.", "type": "integer", "default": 0 }, - "Notifications": { - "$id": "#/properties/Notifications", - "title": "Toggle notifications", - "description": "Enable Windows taskbar notifications on events such as settings reload.", - "type": "boolean", - "default": true - }, - "Opacity": { - "$id": "#/properties/Opacity", - "title": "Window opacity", - "description": "Static window opacity, note that this applies to the entire window, including the tabs bar.", - "type": "integer", - "default": 80 - }, "PreferMonitor": { - "$id": "#/properties/PreferMonitor", "title": "Prefer monitor", "description": "What monitor the terminal should target when toggling on. When using \"AtIndex\" use the \"MonitorIndex\"-setting to determine the monitor.", - "enum": [ - "WithCursor", - "Primary", - "AtIndex" - ], + "enum": [ "WithCursor", "Primary", "AtIndex" ], "default": "WithCursor" - }, - "StartHidden": { - "$id": "#/properties/StartHidden", - "title": "Start hidden", - "description": "Whether to start the Windows Terminal app in the background, for use when eg. running on system boot.", - "type": "boolean", - "default": false - }, - "SuppressHotkeyForProcesses": { - "$id": "#/properties/SuppressHotkeyForProcesses", - "title": "Suppress hotkeys for processes", - "description": "Temporarily disable the toggle hotkeys when any of these processes has focus.", - "type": "array", - "default": [ - "someprocess.exe" - ] - }, - "TaskbarIconVisibility": { - "$id": "#/properties/TaskbarIconVisibility", - "title": "Taskbar icon visibility", - "description": "When to show the terminal window icon on the taskbar.", - "enum": [ - "AlwaysHidden", - "AlwaysVisible", - "WhenTerminalVisible" - ], - "default": "AlwaysHidden" - }, - "ToggleAnimationFrameTimeMs": { - "$id": "#/properties/ToggleAnimationFrameTimeMs", - "title": "Toggle animation frame time ms", - "description": "Target time between animation frames.", - "type": "integer", - "default": 25 - }, - "ToggleAnimationType": { - "$id": "#/properties/ToggleAnimationType", - "title": "Toggle animation type", - "description": "Which animation type is used during toggle up/down.", - "enum": [ - "Linear", - "EaseInBack", - "EaseInCubic", - "EaseInOutSine", - "EaseInQuart", - "EaseOutBack", - "EaseOutCubic", - "EaseOutQuart" - ], - "default": "EaseOutQuart" - }, - "ToggleDurationMs": { - "$id": "#/properties/ToggleDurationMs", - "title": "Toggle duration ms", - "description": "How long the toggle up/down takes in milliseconds.", - "type": "integer", - "default": 250 - }, - "ToggleMode": { - "$id": "#/properties/ToggleMode", - "title": "Toggle mode", - "description": "How the terminal actually gets toggled on- and off the screen.\n\nDefault \"Resize\" should work on any setup, but may cause character jumping due to the terminal changing shape.\n\n\"Move\" prevents this, but may not work with vertical monitor setups, pushing the terminal onto the northern monitor.", - "enum": [ - "Initial", - "Move", - "Resize" - ], - "default": "Move" - }, - "VerticalOffset": { - "$id": "#/properties/VerticalOffset", - "title": "Vertical offset", - "description": "How much room to leave between the top of the terminal and the top of the screen.", - "type": "integer", - "default": 0 - }, - "VerticalScreenCoverage": { - "$id": "#/properties/VerticalScreenCoverage", - "title": "Vertical screen coverage", - "description": "Vertical screen coverage as a percentage (0-100).", - "type": "number", - "default": 100 } } } \ No newline at end of file