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;