diff --git a/wpf/Assets/QuoteFloat.ico b/wpf/Assets/QuoteFloat.ico new file mode 100644 index 0000000..02437ba Binary files /dev/null and b/wpf/Assets/QuoteFloat.ico differ diff --git a/wpf/Assets/QuoteFloat.svg b/wpf/Assets/QuoteFloat.svg new file mode 100644 index 0000000..fb523e2 --- /dev/null +++ b/wpf/Assets/QuoteFloat.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/wpf/Properties/PublishProfiles/WinX64SingleFile.pubxml b/wpf/Properties/PublishProfiles/WinX64SingleFile.pubxml new file mode 100644 index 0000000..7e334c8 --- /dev/null +++ b/wpf/Properties/PublishProfiles/WinX64SingleFile.pubxml @@ -0,0 +1,15 @@ + + + Release + Any CPU + win-x64 + true + true + true + true + false + false + None + false + + diff --git a/wpf/QuotaFloat.Wpf.csproj b/wpf/QuotaFloat.Wpf.csproj index c4e9ef8..d4d2919 100644 --- a/wpf/QuotaFloat.Wpf.csproj +++ b/wpf/QuotaFloat.Wpf.csproj @@ -7,6 +7,7 @@ enable QuotaFloat.Wpf QuotaFloat.Wpf + Assets\QuoteFloat.ico 1.1.0 1.1.0.0 1.1.0.0 diff --git a/wpf/Services/CodexPresenceService.cs b/wpf/Services/CodexPresenceService.cs index dd6d1ed..fd525ea 100644 --- a/wpf/Services/CodexPresenceService.cs +++ b/wpf/Services/CodexPresenceService.cs @@ -42,8 +42,8 @@ public bool Observe(CodexObservation observation) public interface ICodexPresenceSource : IDisposable { - Task AttachAndSampleAsync(CancellationToken cancellationToken); - Task SampleAsync(CancellationToken cancellationToken); + Task AttachAndSampleAsync(bool launchIfMissing, CancellationToken cancellationToken); + Task SampleAsync(bool launchIfMissing, CancellationToken cancellationToken); } public sealed class CodexLifecyclePresenceSource : ICodexPresenceSource @@ -51,18 +51,18 @@ public sealed class CodexLifecyclePresenceSource : ICodexPresenceSource private readonly CodexLifecycle lifecycle = new(); private bool attached; - public async Task AttachAndSampleAsync(CancellationToken cancellationToken) + public async Task AttachAndSampleAsync(bool launchIfMissing, CancellationToken cancellationToken) { try { - attached = await lifecycle.AttachOrLaunchAsync(false, cancellationToken).ConfigureAwait(false); + attached = await lifecycle.AttachOrLaunchAsync(launchIfMissing, cancellationToken).ConfigureAwait(false); if (!attached) { return lifecycle.Status.Contains("not running", StringComparison.OrdinalIgnoreCase) ? new(CodexObservationStatus.Absent, 0, 0) : new(CodexObservationStatus.Unknown, 0, 0); } - return ToObservation(lifecycle.RefreshPresence()); + return ToObservation(lifecycle.RefreshPresence(), lifecycle.DesktopProcessId is not null); } catch (OperationCanceledException) { throw; } catch (Exception) @@ -72,10 +72,10 @@ public async Task AttachAndSampleAsync(CancellationToken cance } } - public async Task SampleAsync(CancellationToken cancellationToken) + public async Task SampleAsync(bool launchIfMissing, CancellationToken cancellationToken) { - if (!attached) return await AttachAndSampleAsync(cancellationToken).ConfigureAwait(false); - try { return ToObservation(lifecycle.RefreshPresence()); } + if (!attached) return await AttachAndSampleAsync(launchIfMissing, cancellationToken).ConfigureAwait(false); + try { return ToObservation(lifecycle.RefreshPresence(), lifecycle.DesktopProcessId is not null); } catch (Exception) { attached = false; @@ -85,8 +85,8 @@ public async Task SampleAsync(CancellationToken cancellationTo public void Dispose() => lifecycle.Dispose(); - private static CodexObservation ToObservation(CodexPresence presence) => + private static CodexObservation ToObservation(CodexPresence presence, bool rootProcessAttached) => new(presence.IsPresent ? CodexObservationStatus.Present : CodexObservationStatus.Absent, presence.VisibleWindowCount, - presence.ProcessIds.Count); + rootProcessAttached ? 1 : 0); } diff --git a/wpf/Services/LaunchOptions.cs b/wpf/Services/LaunchOptions.cs new file mode 100644 index 0000000..0d14f59 --- /dev/null +++ b/wpf/Services/LaunchOptions.cs @@ -0,0 +1,29 @@ +namespace QuotaFloat.Wpf.Services; + +public enum WpfLaunchMode +{ + LaunchAndWatch, + Watch, + Direct, + Invalid +} + +public readonly record struct LaunchOptions(WpfLaunchMode Mode, string? Error) +{ + public bool IsValid => Mode != WpfLaunchMode.Invalid; + public bool LaunchCodexIfMissing => Mode == WpfLaunchMode.LaunchAndWatch; + + public static LaunchOptions Parse(string[] args) + { + var direct = args.Any(a => string.Equals(a, "--direct", StringComparison.OrdinalIgnoreCase)); + var watch = args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)); + if (direct && watch) + { + return new(WpfLaunchMode.Invalid, "Use either --watch or --direct, not both."); + } + + if (direct) return new(WpfLaunchMode.Direct, null); + if (watch) return new(WpfLaunchMode.Watch, null); + return new(WpfLaunchMode.LaunchAndWatch, null); + } +} diff --git a/wpf/Services/TrayIconService.cs b/wpf/Services/TrayIconService.cs index c202d38..3d86b56 100644 --- a/wpf/Services/TrayIconService.cs +++ b/wpf/Services/TrayIconService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; @@ -29,6 +30,7 @@ public sealed class TrayIconService : IDisposable private readonly Action exitApplication; private readonly HwndSource hwndSource; private readonly uint iconId = 0x5146; + private IntPtr productIcon; private bool disposed; private bool iconAdded; @@ -41,7 +43,15 @@ public TrayIconService(Window owner, Action openSettings, Action exitApplication hwndSource = HwndSource.FromHwnd(hwnd) ?? throw new InvalidOperationException("Unable to attach the WPF tray message hook."); hwndSource.AddHook(WindowMessageHook); - AddIcon(hwnd); + try + { + AddIcon(hwnd); + } + catch + { + hwndSource.RemoveHook(WindowMessageHook); + throw; + } } public void Dispose() @@ -59,15 +69,24 @@ public void Dispose() iconAdded = false; } + if (productIcon != IntPtr.Zero) + { + DestroyIcon(productIcon); + productIcon = IntPtr.Zero; + } + hwndSource.RemoveHook(WindowMessageHook); GC.SuppressFinalize(this); } private void AddIcon(IntPtr hwnd) { + productIcon = LoadProductIcon(); var data = CreateIconData(hwnd, NifMessage | NifIcon | NifTip); if (!Shell_NotifyIcon(NimAdd, ref data)) { + DestroyIcon(productIcon); + productIcon = IntPtr.Zero; throw new InvalidOperationException("Unable to create the Windows notification-area icon."); } @@ -81,10 +100,27 @@ private void AddIcon(IntPtr hwnd) uID = iconId, uFlags = flags, uCallbackMessage = WmTrayCallback, - hIcon = LoadIcon(IntPtr.Zero, new IntPtr(32512)), + hIcon = productIcon, szTip = "Quota Float" }; + private static IntPtr LoadProductIcon() + { + var executable = Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrWhiteSpace(executable)) + { + throw new InvalidOperationException("Unable to locate the Quote Float executable icon."); + } + + var small = new IntPtr[1]; + if (ExtractIconEx(executable, 0, null, small, 1) == 0 || small[0] == IntPtr.Zero) + { + throw new InvalidOperationException("Unable to load the Quote Float executable icon."); + } + + return small[0]; + } + private IntPtr WindowMessageHook(IntPtr hwnd, int message, IntPtr wParam, IntPtr lParam, ref bool handled) { if (message != WmTrayCallback || unchecked((uint)wParam.ToInt64()) != iconId) @@ -155,8 +191,12 @@ private void ShowContextMenu(IntPtr hwnd) [return: MarshalAs(UnmanagedType.Bool)] private static extern bool Shell_NotifyIcon(uint message, ref NOTIFYICONDATA data); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern IntPtr LoadIcon(IntPtr instance, IntPtr iconName); + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern uint ExtractIconEx(string fileName, int iconIndex, IntPtr[]? largeIcons, IntPtr[]? smallIcons, uint iconCount); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyIcon(IntPtr icon); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr CreatePopupMenu(); diff --git a/wpf/Services/WpfApplicationCoordinator.cs b/wpf/Services/WpfApplicationCoordinator.cs index 1b2b412..5e75c74 100644 --- a/wpf/Services/WpfApplicationCoordinator.cs +++ b/wpf/Services/WpfApplicationCoordinator.cs @@ -5,15 +5,10 @@ namespace QuotaFloat.Wpf.Services; -public enum WpfLaunchMode -{ - Direct, - Watch -} - public sealed class WpfApplicationCoordinator : IDisposable { private static readonly TimeSpan PresenceInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan CodexStartupVisibilityGrace = TimeSpan.FromSeconds(10); private const int RequiredConsecutiveAbsences = 3; private readonly Dispatcher dispatcher; @@ -25,6 +20,7 @@ public sealed class WpfApplicationCoordinator : IDisposable private CancellationTokenSource? autoRefreshLifetime; private bool stopping; private bool disposed; + private bool launchCodexIfMissing; public WpfApplicationCoordinator(Dispatcher dispatcher) { @@ -33,7 +29,7 @@ public WpfApplicationCoordinator(Dispatcher dispatcher) public MainWindow? Window => window; public QuotaRefreshCoordinator? Quota => quota; - public WpfLaunchMode LaunchMode { get; private set; } = WpfLaunchMode.Direct; + public WpfLaunchMode LaunchMode { get; private set; } = WpfLaunchMode.LaunchAndWatch; public bool Start(string[] args) { @@ -49,7 +45,15 @@ public bool Start(string[] args) { preferences.Current.Language = demoLanguage; } - LaunchMode = ParseLaunchMode(args); + var options = LaunchOptions.Parse(args); + if (!options.IsValid) + { + MessageBox.Show(options.Error!, "Quote Float", MessageBoxButton.OK, MessageBoxImage.Error); + return false; + } + + LaunchMode = options.Mode; + launchCodexIfMissing = options.LaunchCodexIfMissing; var demoStatus = ParseDemoStatus(args); var demoMode = args.Any(a => string.Equals(a, "--demo-state", StringComparison.OrdinalIgnoreCase)) || demoStatus is not null; quota = demoMode ? null : new QuotaRefreshCoordinator(new QuotaClientSource()); @@ -127,9 +131,9 @@ private async Task StartBackgroundWorkAsync(int refreshIntervalSeconds) _ = quota.RefreshAsync(lifetime.Token); ConfigureAutoRefresh(refreshIntervalSeconds); - if (LaunchMode == WpfLaunchMode.Watch) + if (LaunchMode is WpfLaunchMode.Watch or WpfLaunchMode.LaunchAndWatch) { - await WatchCodexAsync(codex, lifetime.Token).ConfigureAwait(false); + await WatchCodexAsync(codex, launchCodexIfMissing, lifetime.Token).ConfigureAwait(false); } } catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { } @@ -148,15 +152,19 @@ private void ConfigureAutoRefresh(int fallbackSeconds) token); } - private async Task WatchCodexAsync(ICodexPresenceSource source, CancellationToken cancellationToken) + private async Task WatchCodexAsync(ICodexPresenceSource source, bool launchIfMissing, CancellationToken cancellationToken) { - var absence = new CodexAbsenceConfirmation(RequiredConsecutiveAbsences); - var initial = await source.AttachAndSampleAsync(cancellationToken).ConfigureAwait(false); + var watch = new CodexWatchSession( + launchIfMissing, + CodexStartupVisibilityGrace, + TimeProvider.System, + RequiredConsecutiveAbsences); + var initial = await source.AttachAndSampleAsync(launchIfMissing, cancellationToken).ConfigureAwait(false); while (!cancellationToken.IsCancellationRequested) { var observation = initial; initial = new(CodexObservationStatus.Unknown, 0, 0); - if (absence.Observe(observation)) + if (watch.Observe(observation)) { _ = dispatcher.BeginInvoke(new Action(RequestExit), DispatcherPriority.ApplicationIdle); return; @@ -165,7 +173,7 @@ private async Task WatchCodexAsync(ICodexPresenceSource source, CancellationToke try { await Task.Delay(PresenceInterval, cancellationToken).ConfigureAwait(false); - initial = await source.SampleAsync(cancellationToken).ConfigureAwait(false); + initial = await source.SampleAsync(launchIfMissing, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } } @@ -189,13 +197,6 @@ private void Window_OnClosed(object? sender, EventArgs e) Application.Current.Shutdown(); } - private static WpfLaunchMode ParseLaunchMode(string[] args) - { - if (args.Any(a => string.Equals(a, "--direct", StringComparison.OrdinalIgnoreCase))) return WpfLaunchMode.Direct; - if (args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase))) return WpfLaunchMode.Watch; - return WpfLaunchMode.Watch; - } - private static int ParseMilliseconds(string[] args, string option) { for (var i = 0; i < args.Length - 1; i++) @@ -262,3 +263,52 @@ private static (bool pro, bool orb, bool explicitState) ParseDemoState(string[] } } + +public sealed class CodexWatchSession +{ + private readonly bool startupGraceEnabled; + private readonly TimeSpan startupGrace; + private readonly TimeProvider timeProvider; + private readonly CodexAbsenceConfirmation absence; + private DateTimeOffset? startupGraceDeadline; + private bool observedPresent; + + public CodexWatchSession( + bool launchIfMissing, + TimeSpan startupGrace, + TimeProvider? timeProvider = null, + int requiredAbsences = 3) + { + if (startupGrace <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(startupGrace)); + startupGraceEnabled = launchIfMissing; + this.startupGrace = startupGrace; + this.timeProvider = timeProvider ?? TimeProvider.System; + absence = new CodexAbsenceConfirmation(requiredAbsences); + } + + public bool Observe(CodexObservation observation) + { + if (observation.Status == CodexObservationStatus.Present) + { + observedPresent = true; + startupGraceDeadline = null; + return absence.Observe(observation); + } + + if (!observedPresent && startupGraceEnabled && observation.Status == CodexObservationStatus.Absent) + { + var now = timeProvider.GetUtcNow(); + if (startupGraceDeadline is null && observation.ProcessCount > 0) + { + startupGraceDeadline = now + startupGrace; + } + + if (startupGraceDeadline is { } deadline && now < deadline) + { + return false; + } + } + + return absence.Observe(observation); + } +} diff --git a/wpf/tests/Program.cs b/wpf/tests/Program.cs index 69b06df..36a1711 100644 --- a/wpf/tests/Program.cs +++ b/wpf/tests/Program.cs @@ -239,6 +239,46 @@ void Check(bool condition, string label) Check(absence.Observe(new(CodexObservationStatus.Absent, 0, 0)), "third consecutive absence confirms close"); Check(!absence.Observe(new(CodexObservationStatus.Present, 1, 1)), "presence clears confirmation"); +var startupClock = new ManualTimeProvider(DateTimeOffset.UnixEpoch); +var startupWatch = new CodexWatchSession(true, TimeSpan.FromSeconds(5), startupClock); +Check(!startupWatch.Observe(new(CodexObservationStatus.Absent, 0, 1)), "launch root before visible window does not confirm close"); +Check(!startupWatch.Observe(new(CodexObservationStatus.Absent, 0, 1)), "startup visibility grace does not consume absences"); +Check(!startupWatch.Observe(new(CodexObservationStatus.Present, 1, 1)), "first visible window arms normal absence confirmation"); + +var armedWatch = new CodexWatchSession(true, TimeSpan.FromSeconds(5), startupClock); +armedWatch.Observe(new(CodexObservationStatus.Present, 1, 1)); +Check(!armedWatch.Observe(new(CodexObservationStatus.Absent, 0, 0)) + && !armedWatch.Observe(new(CodexObservationStatus.Absent, 0, 0)) + && armedWatch.Observe(new(CodexObservationStatus.Absent, 0, 0)), + "three absences after observed presence confirm close"); + +var noWindowWatch = new CodexWatchSession(true, TimeSpan.FromSeconds(5), startupClock); +noWindowWatch.Observe(new(CodexObservationStatus.Absent, 0, 1)); +startupClock.Advance(TimeSpan.FromSeconds(5)); +Check(!noWindowWatch.Observe(new(CodexObservationStatus.Absent, 0, 0)) + && !noWindowWatch.Observe(new(CodexObservationStatus.Absent, 0, 0)) + && noWindowWatch.Observe(new(CodexObservationStatus.Absent, 0, 0)), + "bounded startup grace eventually confirms no-window close"); + +var passiveWatchSession = new CodexWatchSession(false, TimeSpan.FromSeconds(5), startupClock); +Check(!passiveWatchSession.Observe(new(CodexObservationStatus.Absent, 0, 1)) + && !passiveWatchSession.Observe(new(CodexObservationStatus.Absent, 0, 1)) + && passiveWatchSession.Observe(new(CodexObservationStatus.Absent, 0, 1)), + "passive watch keeps existing absence semantics"); + +var launchAndWatch = LaunchOptions.Parse(Array.Empty()); +Check(launchAndWatch.Mode == WpfLaunchMode.LaunchAndWatch && launchAndWatch.LaunchCodexIfMissing, + "no arguments launch and watch through Codex activation"); +var passiveWatch = LaunchOptions.Parse(new[] { "--watch" }); +Check(passiveWatch.Mode == WpfLaunchMode.Watch && !passiveWatch.LaunchCodexIfMissing, + "--watch remains passive"); +var direct = LaunchOptions.Parse(new[] { "--direct" }); +Check(direct.Mode == WpfLaunchMode.Direct && !direct.LaunchCodexIfMissing, + "--direct remains independent"); +var conflicting = LaunchOptions.Parse(new[] { "--watch", "--direct" }); +Check(!conflicting.IsValid && conflicting.Mode == WpfLaunchMode.Invalid, + "conflicting launch modes fail deterministically"); + using (var primary = SingleInstanceService.Acquire(() => { })) using (var secondary = SingleInstanceService.Acquire(() => { })) { @@ -252,9 +292,20 @@ void Check(bool condition, string label) } Console.WriteLine("PASS: QF-WPF-009 focused fixtures and orchestration checks"); -Console.WriteLine($"RESULTS: fixtures=31; checks={checkCount}; activeRequests={source.MaxActive}; refreshCalls={source.CallCount}; backoffCalls={backoffSource.CallCount}; privacy=normalized-values-only"); +Console.WriteLine($"RESULTS: fixtures=35; checks={checkCount}; activeRequests={source.MaxActive}; refreshCalls={source.CallCount}; backoffCalls={backoffSource.CallCount}; privacy=normalized-values-only"); return 0; +sealed class ManualTimeProvider : TimeProvider +{ + private DateTimeOffset now; + + public ManualTimeProvider(DateTimeOffset initial) => now = initial; + + public override DateTimeOffset GetUtcNow() => now; + + public void Advance(TimeSpan amount) => now += amount; +} + sealed class FakeQuotaSource : IQuotaSource { private readonly Queue results;