diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48da758..0efe5c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# changelog
+## unreleased - panel efficiency
+
+- preserved the installed popup's layout, fonts, themes, controls and navigation.
+- kept controls intact on timestamp-only refreshes and reused frozen brushes.
+- replaced frequent file checks with debounced notifications and a recovery timer.
+- paced live-glass handoff to its 60 Hz capture rate while retaining display-rate
+ entrance and exit motion; stopped capture workers before reopening.
+- bounded panel error logs and closed window-owned timers and capture resources.
+- added reproducible, isolated memory, refresh and navigation checks; see
+ [performance checks](docs/performance.md). collectors and account behavior are
+ unchanged.
+
## 0.4.1 - 2026-10-26
- changed **Dashboard** to switch pages inside the existing tray popup. removed
diff --git a/docs/performance.md b/docs/performance.md
new file mode 100644
index 0000000..3eda0fa
--- /dev/null
+++ b/docs/performance.md
@@ -0,0 +1,66 @@
+# panel performance checks
+
+Fleet keeps its existing native WPF panel, controls, fonts, themes and collectors.
+The panel reuses frozen brushes, keeps its controls on timestamp-only model
+updates, and watches cache-file notifications with a one-second recovery timer.
+The normal tray-window message path is unchanged. File notifications are debounced
+so creating and writing a request does not toggle the popup twice.
+
+Live glass still captures up to 60 frames per second. Its UI handoff now runs only
+at that rate, instead of subscribing continuously to display-rate rendering.
+Entrance and exit animations retain their display-rate animation. Closing stops
+and joins the capture worker; closing the window disposes its watcher, timers and
+capture resources. Error logging keeps two bounded files and omits exception
+messages that could contain input.
+
+## reproduce
+
+Use a synthetic `panel.json` generated with `panel_model.build`, including
+`summary`, `coverage`, `api_accounts` and dashboard history. Do not copy credentials
+or a user's raw logs into a fixture. Install `pythonnet` and `psutil` in a test
+environment. Both checks require Windows; neither collects provider data.
+
+```powershell
+python scripts/measure-panel.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR 3
+python scripts/check-panel-performance.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR
+python scripts/check-dashboard.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR
+```
+
+Use different output directories for each binary and run. Keep all generated
+artifacts, virtual environments, package caches, TEMP and TMP on the requested
+scratch/output drive. Do not run performance checks concurrently.
+
+`measure-panel.py` starts real standalone panel processes with isolated caches,
+samples fresh-hidden, open and closed-after-open states, then requests graceful
+exit. Its windows stay offscreen and use the real open/close and capture paths.
+It reports private resident memory (USS), total working set, private commit,
+process CPU time, handles and threads separately. CPU time is per process, not a
+percentage of every logical processor.
+
+`check-panel-performance.py` hosts the same assembly in Python/CLR to exercise 100
+timestamp-only refreshes and 100 open/close/navigation cycles. It checks that
+changed content still replaces controls, and that coverage, settings, summary and
+account changes are not lost. Its memory samples include Python/CLR test overhead
+and must not be presented as the standalone app's footprint. Managed allocation
+totals include the host's other managed work. No check forces garbage collection
+or trims the working set.
+
+## acceptance and limits
+
+Compare identical fixtures, display settings, timing and process age. Separate
+startup memory from the memory after interaction. Check late-cycle ranges for
+private resident memory, working set, private commit, kernel handles, GDI objects
+and threads. A short plateau is evidence for that workload, not a guarantee about
+multi-day behavior.
+
+For appearance, render each binary with `--render` against the same synthetic
+model and background. Compare usage, dashboard, settings and expanded-provider
+pages in both themes. Static pixel parity does not by itself prove animation,
+multi-monitor, or live-background parity; use the dashboard interaction check and
+a real desktop smoke check as well.
+
+Unit tests should not depend on a developer's real Credential Manager entries.
+An unisolated provider-enablement test can discover an already-connected admin
+account. For reproducible local tests, mock `api_credentials.read` to return
+`None` around test discovery/execution; credential-specific tests use their own
+mocks. Never clear real credentials to make a test pass.
diff --git a/native/FleetPanel/LiveBackdrop.cs b/native/FleetPanel/LiveBackdrop.cs
index 5f9509c..24aebc5 100644
--- a/native/FleetPanel/LiveBackdrop.cs
+++ b/native/FleetPanel/LiveBackdrop.cs
@@ -16,6 +16,7 @@ internal sealed class LiveBackdrop
private readonly object gate = new object();
private Thread thread;
private volatile bool running;
+ private readonly ManualResetEventSlim stopping = new ManualResetEventSlim(false);
private int left, top, width, height;
private byte[] frame;
private int frameWidth, frameHeight;
@@ -41,6 +42,7 @@ public void Start(int l, int t, int w, int h)
totalMs = 0;
}
running = true;
+ stopping.Reset();
thread = new Thread(Run) { IsBackground = true, Name = "fleet-glass" };
thread.Start();
}
@@ -56,7 +58,15 @@ public void Move(int l, int t, int w, int h)
}
}
- public void Stop() => running = false;
+ public void Stop()
+ {
+ running = false;
+ stopping.Set();
+ // A new open must not revive the previous worker before it exits.
+ // Wake its paced wait immediately; only an in-flight copy can remain.
+ thread?.Join();
+ thread = null;
+ }
/// The newest frame, if one arrived since the last call.
public bool TryTake(ref byte[] target, out int w, out int h, out double lum)
@@ -100,7 +110,7 @@ private void Run()
}
if (w <= 0 || h <= 0)
{
- Thread.Sleep(16);
+ stopping.Wait(16);
continue;
}
var cw = Math.Max(1, w / ScreenCapture.Downscale);
@@ -128,7 +138,7 @@ private void Run()
clock.Stop();
if (!ok)
{
- Thread.Sleep(16);
+ stopping.Wait(16);
continue;
}
// Sixty copies a second while something behind moves: under a blur that
@@ -144,7 +154,7 @@ private void Run()
}
if (rest >= 1)
{
- Thread.Sleep((int)Math.Ceiling(rest));
+ stopping.Wait((int)Math.Ceiling(rest));
}
lock (gate)
{
@@ -174,16 +184,18 @@ private void Run()
private static void Free(ref IntPtr dc, ref IntPtr dib)
{
- if (dib != IntPtr.Zero)
- {
- Native.DeleteObject(dib);
- dib = IntPtr.Zero;
- }
+ // Release the DC's selection before deleting its bitmap. GDI refuses
+ // DeleteObject while the bitmap is still selected into a live DC.
if (dc != IntPtr.Zero)
{
Native.DeleteDC(dc);
dc = IntPtr.Zero;
}
+ if (dib != IntPtr.Zero)
+ {
+ Native.DeleteObject(dib);
+ dib = IntPtr.Zero;
+ }
}
}
}
diff --git a/native/FleetPanel/Model.cs b/native/FleetPanel/Model.cs
index 570bbbd..838f137 100644
--- a/native/FleetPanel/Model.cs
+++ b/native/FleetPanel/Model.cs
@@ -11,6 +11,9 @@ namespace FleetPanel
internal sealed class PanelData
{
public double Ts;
+ // Compare every field except the collection timestamp. Freshness, account
+ // status and coverage timestamps remain part of the displayed content.
+ public string ContentKey;
public int Live;
public int Cached;
public List Providers = new List();
@@ -27,7 +30,8 @@ internal sealed class PanelData
public static PanelData Load(string path)
{
var text = File.ReadAllText(path);
- var root = new JavaScriptSerializer { MaxJsonLength = 8 * 1024 * 1024 }.DeserializeObject(text)
+ var serializer = new JavaScriptSerializer { MaxJsonLength = 8 * 1024 * 1024 };
+ var root = serializer.DeserializeObject(text)
as Dictionary;
var data = new PanelData();
if (root == null)
@@ -150,6 +154,8 @@ public static PanelData Load(string path)
data.SkippedArchives = Num(coverage, "skipped_zst"); data.LogUpdated = Num(coverage, "updated");
data.LogSince = Num(coverage, "since");
}
+ root.Remove("ts");
+ data.ContentKey = serializer.Serialize(root);
return data;
}
diff --git a/native/FleetPanel/PanelView.cs b/native/FleetPanel/PanelView.cs
index 697be45..f996aaa 100644
--- a/native/FleetPanel/PanelView.cs
+++ b/native/FleetPanel/PanelView.cs
@@ -39,8 +39,8 @@ internal sealed partial class PanelView
// there, and shows as a hard square around the flyout.
public const double Margin = 28;
public const double Width = CardWidth + 2 * Margin;
- public const double Radius = 8;
- private const double Pad = 16;
+ public const double Radius = 16;
+ private const double Pad = 18;
// Meters line up under the provider's name, past its icon.
private const double Indent = 26;
private const double LabelColumn = 64;
@@ -69,6 +69,10 @@ internal sealed partial class PanelView
private readonly List tickers = new List();
private readonly Dictionary sections = new Dictionary();
private readonly Dictionary masks = new Dictionary();
+ // Frozen brushes are safe to share, including the small custom drawings.
+ // Bound this cache even if a future theme supplies arbitrary colours.
+ private static readonly Dictionary brushes = new Dictionary();
+ private string renderedKey;
private readonly RotateTransform spin = new RotateTransform();
private PanelData data = new PanelData();
private string page = "usage";
@@ -118,7 +122,7 @@ public PanelView(Theme theme)
Background = new SolidColorBrush(theme.Fallback),
Effect = new DropShadowEffect
{
- BlurRadius = 18, ShadowDepth = 7, Direction = 270, Opacity = theme.ShadowOpacity, Color = theme.Shadow,
+ BlurRadius = 24, ShadowDepth = 5, Direction = 270, Opacity = theme.ShadowOpacity, Color = theme.Shadow,
RenderingBias = RenderingBias.Quality,
},
IsHitTestVisible = false,
@@ -237,7 +241,13 @@ public void Show(PanelData next, bool animate = false)
}
// A scheduled data push must not erase a key while the user types.
if (page == "settings" && apiEditing != "") return;
- if (apiFeedback == "Key saved. Checking connection…") apiFeedback = "";
+ var feedbackChanged = apiFeedback == "Key saved. Checking connection…";
+ if (feedbackChanged) apiFeedback = "";
+ if (!feedbackChanged && data.ContentKey != null && renderedKey == data.ContentKey)
+ {
+ Tick();
+ return;
+ }
Rebuild(keepScroll: true);
}
@@ -283,6 +293,7 @@ public void Select(string id)
private void Rebuild(bool keepScroll = false)
{
+ renderedKey = data.ContentKey;
var offset = keepScroll ? scroller.VerticalOffset : 0;
apiInput?.Clear();
apiInput = null;
@@ -324,7 +335,6 @@ private void Rebuild(bool keepScroll = false)
}
foreach (var provider in data.Providers)
{
- content.Children.Add(Divider());
content.Children.Add(Section(provider));
}
foreach (var item in data.Inactive)
@@ -343,13 +353,15 @@ private void Rebuild(bool keepScroll = false)
/// Windows' "Animation effects" switch: off means no motion at all.
private static bool Motion => SystemParameters.ClientAreaAnimation;
- /// Far enough for the whole card to sit below the taskbar's edge.
- private double Drop => Math.Max(64, card.ActualHeight + 16);
+ /// A short travel keeps large popups from sweeping across the screen.
+ private double Drop => 12;
/// Before showing: the card waits below the taskbar's edge.
public void PrepareIntro()
{
lift.BeginAnimation(TranslateTransform.YProperty, null);
+ card.BeginAnimation(UIElement.OpacityProperty, null);
+ card.Opacity = Motion ? 0 : 1;
lift.Y = Motion ? Drop : 0;
}
@@ -360,11 +372,19 @@ public void PlayIntro()
{
lift.BeginAnimation(TranslateTransform.YProperty, null);
lift.Y = 0;
+ card.Opacity = 1;
+ card.CacheMode = null;
IntroMeasured?.Invoke("frames=0 motion=off");
return;
}
- busyUntil = DateTime.UtcNow.AddMilliseconds(360);
- lift.BeginAnimation(TranslateTransform.YProperty, Anim(Drop, 0, 330, 0, Decelerate));
+ busyUntil = DateTime.UtcNow.AddMilliseconds(220);
+ // Cache the whole surface only during travel: nested blur/shadow layers
+ // should not be recomposed at every animation frame. Text is live at rest.
+ card.CacheMode = new BitmapCache { SnapsToDevicePixels = true };
+ var entrance = Anim(lift.Y, 0, 200, 0, Decelerate);
+ entrance.Completed += (s, e) => card.CacheMode = null;
+ lift.BeginAnimation(TranslateTransform.YProperty, entrance);
+ card.BeginAnimation(UIElement.OpacityProperty, Anim(card.Opacity, 1, 150, 0, Decelerate));
var started = DateTime.UtcNow;
var frames = new List();
if (counting != null)
@@ -411,6 +431,7 @@ private static string Pacing(List frames)
/// Stop everything that runs per frame: nothing renders while closed.
public void Rest()
{
+ card.CacheMode = null;
spin.BeginAnimation(RotateTransform.AngleProperty, null);
spin.Angle = 0;
if (counting != null)
@@ -423,8 +444,10 @@ public void Rest()
/// The close: the card drops back into the taskbar, speeding up as it goes.
public void PlayOutro(Action finished)
{
- busyUntil = DateTime.UtcNow.AddMilliseconds(220);
- var drop = Anim(lift.Y, Drop, 190, 0, Accelerate);
+ busyUntil = DateTime.UtcNow.AddMilliseconds(140);
+ card.CacheMode = new BitmapCache { SnapsToDevicePixels = true };
+ card.BeginAnimation(UIElement.OpacityProperty, Anim(card.Opacity, 0, 110, 0, Accelerate));
+ var drop = Anim(lift.Y, Drop, 120, 0, Accelerate);
drop.Completed += (s, e) => finished();
lift.BeginAnimation(TranslateTransform.YProperty, drop);
}
@@ -447,7 +470,7 @@ private static DoubleAnimation Anim(double from, double to, double ms, double de
private UIElement Header(string title, bool back)
{
- var grid = new Grid { Height = 40 };
+ var grid = new Grid { Height = 46 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var left = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
@@ -457,7 +480,9 @@ private UIElement Header(string title, bool back)
button.Margin = new Thickness(-8, 0, 4, 0);
left.Children.Add(button);
}
- left.Children.Add(Text(title, 14, theme.Text, FontWeights.SemiBold));
+ if (!back)
+ left.Children.Add(new Border { Width = 6, Height = 18, CornerRadius = new CornerRadius(3), Background = Brush(theme.Accent), Margin = new Thickness(0, 0, 8, 0) });
+ left.Children.Add(Text(title, 16, theme.Text, FontWeights.SemiBold));
grid.Children.Add(left);
if (!back || page == "dashboard")
{
@@ -498,7 +523,7 @@ private UIElement Band()
note.Margin = new Thickness(0, 0, 0, 10);
return note;
}
- var grid = new Grid { Margin = new Thickness(0, 2, 0, 12), ToolTip = CostNote, Background = Brushes.Transparent };
+ var grid = new Grid { Margin = new Thickness(12, 12, 12, 12), ToolTip = CostNote, Background = Brushes.Transparent };
ToolTipService.SetInitialShowDelay(grid, 400);
var cells = new[]
{
@@ -510,14 +535,14 @@ private UIElement Band()
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(i == 0 ? 1.25 : 1, GridUnitType.Star) });
var cell = new StackPanel();
- cell.Children.Add(Text(cells[i].Item1, 12, theme.TextSecondary));
- var value = Text(cells[i].Item2 == null ? "—" : Format.Money(cells[i].Item2.Value), 20, theme.Text, FontWeights.SemiBold, tabular: true);
+ cell.Children.Add(Text(cells[i].Item1, 11, theme.TextSecondary));
+ var value = Text(cells[i].Item2 == null ? "—" : Format.Money(cells[i].Item2.Value), 20, i == 0 ? theme.Accent : theme.Text, FontWeights.SemiBold, tabular: true);
value.Margin = new Thickness(0, 1, 0, 0);
cell.Children.Add(value);
Grid.SetColumn(cell, i);
grid.Children.Add(cell);
}
- return grid;
+ return new Border { Child = grid, CornerRadius = new CornerRadius(12), Background = Brush(Theme.WithAlpha(theme.Accent, theme.Light ? 0.07 : 0.10)), Margin = new Thickness(0, 2, 0, 12) };
}
private Border Divider() => new Border
@@ -581,17 +606,17 @@ private FrameworkElement Section(Provider p)
var hit = new Border
{
Child = summary,
- Padding = new Thickness(8, 7, 8, 7),
- Margin = new Thickness(-8, 0, -8, 0),
- CornerRadius = new CornerRadius(6),
- Background = Brushes.Transparent,
+ Padding = new Thickness(10, 10, 10, 10),
+ Margin = new Thickness(0, 0, 0, 7),
+ CornerRadius = new CornerRadius(11),
+ Background = Brush(theme.ControlFill),
};
if (expandable)
{
hit.Cursor = Cursors.Hand;
AutomationName(hit, p.Name + (open ? ", details shown" : ", show details"));
- hit.MouseEnter += (s, e) => hit.Background = Brush(theme.Hover);
- hit.MouseLeave += (s, e) => hit.Background = Brushes.Transparent;
+ hit.MouseEnter += (s, e) => hit.Background = Brush(theme.ControlHover);
+ hit.MouseLeave += (s, e) => hit.Background = Brush(theme.ControlFill);
OnClick(hit, () => Toggle(p.Id));
}
@@ -1792,9 +1817,14 @@ private static void AutomationName(DependencyObject element, string name) =>
private static SolidColorBrush Brush(Color color)
{
- var brush = new SolidColorBrush(color);
- brush.Freeze();
- return brush;
+ lock (brushes)
+ {
+ if (brushes.TryGetValue(color, out var existing)) return existing;
+ var brush = new SolidColorBrush(color);
+ brush.Freeze();
+ if (brushes.Count < 128) brushes[color] = brush;
+ return brush;
+ }
}
}
}
diff --git a/native/FleetPanel/PanelWindow.cs b/native/FleetPanel/PanelWindow.cs
index ee2c9e0..762b077 100644
--- a/native/FleetPanel/PanelWindow.cs
+++ b/native/FleetPanel/PanelWindow.cs
@@ -2,6 +2,7 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
+using System.Threading;
using System.Web.Script.Serialization;
using System.Windows;
using System.Windows.Input;
@@ -40,7 +41,11 @@ internal sealed class PanelWindow : Window
private readonly ScreenCapture capture = new ScreenCapture();
// Input priority sits above rendering: a busy high-refresh render loop must
// never starve the timer that hears the tray and writes the heartbeat.
- private readonly DispatcherTimer signalTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(100) };
+ private readonly DispatcherTimer signalTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromSeconds(1) };
+ private FileSystemWatcher signals;
+ private int signalQueued;
+ private volatile bool closed;
+ private readonly DispatcherTimer signalDebounce = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(50) };
private readonly DispatcherTimer clockTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromSeconds(1) };
private readonly DispatcherTimer dismissTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(90) };
// Click-away watching only runs while the flyout is open.
@@ -99,6 +104,7 @@ public PanelWindow(string cache)
dismissTimer.Tick += (s, e) => { dismissTimer.Stop(); if (!IsActive && open) Close(animate: true); };
KeyDown += (s, e) => { if (e.Key == Key.Escape) Close(animate: true); };
signalTimer.Tick += (s, e) => OnSignalTick();
+ signalDebounce.Tick += (s, e) => { signalDebounce.Stop(); pendingModel = true; OnSignalTick(); };
clockTimer.Tick += (s, e) => view.Tick();
clickTimer.Tick += (s, e) => { if (open && !closing) WatchOutsideClicks(); };
}
@@ -128,10 +134,52 @@ public void Start()
};
warm.Start();
signalSeen = Mtime("panel.show");
+ // Native messages remain the normal instant path. File notifications
+ // keep the fallback responsive without checking the disk ten times/s.
+ try
+ {
+ signals = new FileSystemWatcher(cache) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite };
+ signals.Changed += SignalChanged;
+ signals.Created += SignalChanged;
+ signals.Renamed += SignalChanged;
+ signals.EnableRaisingEvents = true;
+ }
+ catch (IOException) { signals?.Dispose(); signals = null; }
signalTimer.Start();
WriteHeartbeat();
}
+ private void SignalChanged(object sender, FileSystemEventArgs e)
+ {
+ if (e.Name != "panel.show" && e.Name != "dashboard.show" && e.Name != "panel.quit" && e.Name != "panel.json") return;
+ if (closed || Dispatcher.HasShutdownStarted || Interlocked.Exchange(ref signalQueued, 1) != 0) return;
+ try { Dispatcher.BeginInvoke(new Action(() =>
+ {
+ Interlocked.Exchange(ref signalQueued, 0);
+ if (closed) return;
+ // Create/write/rename can all describe the same request. Let the
+ // writer finish before reading its timestamp and toggling once.
+ signalDebounce.Stop();
+ signalDebounce.Start();
+ }), DispatcherPriority.Input); }
+ catch (InvalidOperationException) { Interlocked.Exchange(ref signalQueued, 0); }
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ closed = true;
+ signals?.Dispose();
+ signalTimer.Stop();
+ signalDebounce.Stop();
+ clockTimer.Stop();
+ dismissTimer.Stop();
+ clickTimer.Stop();
+ StopLiveGlass();
+ view.Rest();
+ capture.Dispose();
+ base.OnClosed(e);
+ }
+
private void BuildView()
{
view = new PanelView(Theme.Create(light));
@@ -369,7 +417,7 @@ private void LoadModel()
catch (Exception exception)
{
modelSeen = stamp;
- Note("panel.json unreadable: " + exception.Message);
+ Note("panel.json unreadable: " + exception.GetType().Name);
}
}
@@ -412,13 +460,7 @@ private bool WriteDirect(string name, string text)
private void Note(string message)
{
- try
- {
- File.AppendAllText(PathOf("panel-error.log"), DateTime.Now.ToString("s") + " " + message + Environment.NewLine);
- }
- catch (Exception)
- {
- }
+ Program.Note(cache, message);
}
private void Touch(string name)
@@ -699,6 +741,11 @@ private bool Cloaked() =>
// UI swaps in the newest copy when nothing is moving, about 0.1 ms. Capture
// count and cost are logged on close.
private readonly LiveBackdrop live = new LiveBackdrop();
+ // Capture produces at most 60 frames/s. A permanent Rendering handler
+ // otherwise keeps WPF requesting frames at the monitor's full refresh
+ // rate even when no new pixels arrived. Entrance/exit motion retains
+ // its independent display-rate animation and frame measurement.
+ private readonly DispatcherTimer liveTimer = new DispatcherTimer(DispatcherPriority.Render) { Interval = TimeSpan.FromSeconds(1.0 / 60) };
private EventHandler liveFrame;
private WriteableBitmap liveBitmap;
private byte[] liveBytes;
@@ -709,10 +756,17 @@ private void StartLiveGlass()
{
return;
}
- live.Start(placedLeft, placedTop, placedWidth, placedHeight);
+ var started = false;
liveFrame = (s, e) =>
{
- if (!open || closing || view.Animating || !live.TryTake(ref liveBytes, out var w, out var h, out var lum))
+ if (!open || closing || view.Animating) return;
+ // Screen capture competes with composition; begin it after the entrance.
+ if (!started)
+ {
+ live.Start(placedLeft, placedTop, placedWidth, placedHeight);
+ started = true;
+ }
+ if (!live.TryTake(ref liveBytes, out var w, out var h, out var lum))
{
return;
}
@@ -723,7 +777,8 @@ private void StartLiveGlass()
liveBitmap.WritePixels(new Int32Rect(0, 0, w, h), liveBytes, w * 4, 0);
view.SetBackdrop(liveBitmap, lum);
};
- CompositionTarget.Rendering += liveFrame;
+ liveTimer.Tick += liveFrame;
+ liveTimer.Start();
}
private void StopLiveGlass()
@@ -733,7 +788,8 @@ private void StopLiveGlass()
{
return;
}
- CompositionTarget.Rendering -= liveFrame;
+ liveTimer.Stop();
+ liveTimer.Tick -= liveFrame;
liveFrame = null;
var captures = live.Captures;
if (captures > 0)
diff --git a/native/FleetPanel/Program.cs b/native/FleetPanel/Program.cs
index a2dd5e0..2b47ac9 100644
--- a/native/FleetPanel/Program.cs
+++ b/native/FleetPanel/Program.cs
@@ -56,14 +56,9 @@ public static int Main(string[] args)
// A fault in the flyout leaves a note for the tray log instead of vanishing.
app.DispatcherUnhandledException += (s, e) =>
{
- try
- {
- File.AppendAllText(Path.Combine(cache, "panel-error.log"),
- DateTime.Now.ToString("s") + " " + e.Exception + Environment.NewLine);
- }
- catch (Exception)
- {
- }
+ // Exception messages may include input. Keep type and stack,
+ // never credential text, and bound both the entry and the file.
+ Note(cache, e.Exception.GetType().FullName + Environment.NewLine + e.Exception.StackTrace);
};
WatchParent(app);
var window = new PanelWindow(cache);
@@ -79,6 +74,23 @@ private static string Arg(string[] args, string name)
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
+ internal static void Note(string cache, string message)
+ {
+ try
+ {
+ var path = Path.Combine(cache, "panel-error.log");
+ if (File.Exists(path) && new FileInfo(path).Length > 64 * 1024)
+ {
+ var previous = path + ".previous";
+ if (File.Exists(previous)) File.Delete(previous);
+ File.Move(path, previous);
+ }
+ if (message.Length > 4096) message = message.Substring(0, 4096);
+ File.AppendAllText(path, DateTime.Now.ToString("s") + " " + message + Environment.NewLine);
+ }
+ catch (Exception) { }
+ }
+
/// No orphan flyout: leave when the tray that started us is gone.
private static void WatchParent(Application app)
{
diff --git a/native/FleetPanel/ScreenCapture.cs b/native/FleetPanel/ScreenCapture.cs
index 26e5a20..76a0fa5 100644
--- a/native/FleetPanel/ScreenCapture.cs
+++ b/native/FleetPanel/ScreenCapture.cs
@@ -132,8 +132,8 @@ private void Allocate(int w, int h)
private void Release()
{
- if (dib != IntPtr.Zero) { Native.DeleteObject(dib); dib = IntPtr.Zero; }
if (memoryDc != IntPtr.Zero) { Native.DeleteDC(memoryDc); memoryDc = IntPtr.Zero; }
+ if (dib != IntPtr.Zero) { Native.DeleteObject(dib); dib = IntPtr.Zero; }
}
public void Dispose() => Release();
diff --git a/native/FleetPanel/Theme.cs b/native/FleetPanel/Theme.cs
index e4d5a91..2a18a7d 100644
--- a/native/FleetPanel/Theme.cs
+++ b/native/FleetPanel/Theme.cs
@@ -73,9 +73,9 @@ public static Theme Create(bool light)
StrongStroke = C(0x72000000), StrongFill = C(0x72000000), OnAccent = C(0xFFFFFFFF),
Track = C(0x14000000), Accent = accent,
Caution = C(0xFFE08A00), Critical = C(0xFFC42B1C), CautionText = C(0xFF9D5D00), CriticalText = C(0xFFC42B1C),
- Shadow = C(0xFF000000), ShadowOpacity = 0.20,
+ Shadow = C(0xFF172238), ShadowOpacity = 0.15,
Fallback = C(0xFFF3F3F3), Luminosity = C(0xFFF9F9F9), Tint = C(0xFFFCFCFC),
- LuminosityOpacity = 0.86, TintOpacity = 0.0, Grain = 0.022,
+ LuminosityOpacity = 0.90, TintOpacity = 0.0, Grain = 0.010,
};
}
return new Theme
@@ -88,9 +88,9 @@ public static Theme Create(bool light)
StrongStroke = C(0x8BFFFFFF), StrongFill = C(0x8BFFFFFF), OnAccent = C(0xFF000000),
Track = C(0x1FFFFFFF), Accent = accent,
Caution = C(0xFFF7C948), Critical = C(0xFFFF6B78), CautionText = C(0xFFFCE100), CriticalText = C(0xFFFF99A4),
- Shadow = C(0xFF000000), ShadowOpacity = 0.42,
+ Shadow = C(0xFF000000), ShadowOpacity = 0.32,
Fallback = C(0xFF202020), Luminosity = C(0xFF2C2C2C), Tint = C(0xFF2C2C2C),
- LuminosityOpacity = 0.92, TintOpacity = 0.32, Grain = 0.022,
+ LuminosityOpacity = 0.92, TintOpacity = 0.32, Grain = 0.010,
};
}
diff --git a/scripts/check-dashboard.py b/scripts/check-dashboard.py
index c7d1741..4eac11a 100644
--- a/scripts/check-dashboard.py
+++ b/scripts/check-dashboard.py
@@ -31,7 +31,9 @@
flags=BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.Instance|BindingFlags.Static
checks=[]
def call(obj,name,*args):
- return obj.GetType().GetMethod(name,flags).Invoke(obj,Array[Object](args))
+ method=next(m for m in obj.GetType().GetMethods(flags)
+ if m.Name==name and len(m.GetParameters())==len(args))
+ return method.Invoke(obj,Array[Object](args))
def field(obj,name):
return obj.GetType().GetField(name,flags).GetValue(obj)
def walk(root):
@@ -116,6 +118,23 @@ def verify():
assert app.Windows.Count==1
assert not (scratch/'panel-error.log').exists()
checks.append('closing resets to usage; no extra window or native error')
+ card=field(view,'card');lift=field(view,'lift')
+ for i in range(4):
+ call(parent,'Open');pump(450)
+ assert abs(lift.Y)<0.01 and abs(card.Opacity-1)<0.01
+ assert card.CacheMode is None
+ call(parent,'Close',Boolean(True));pump(200)
+ assert not field(parent,'open') and parent.Left < -10000
+ checks.append('four complete open/close cycles settle opaque, uncached and offscreen')
+ call(parent,'Open');pump(40)
+ call(parent,'Close',Boolean(True));pump(30)
+ call(parent,'Open');pump(450)
+ assert field(parent,'open') and not field(parent,'closing') and abs(card.Opacity-1)<0.01
+ checks.append('rapid close/reopen stays open and restores opacity')
+ call(parent,'Close',Boolean(True));pump(200)
+ assert lift.Y<=12.01 and card.CacheMode is None
+ checks.append('travel is bounded to 12 DIP and no animation cache remains while closed')
+ assert not (scratch/'panel-error.log').exists()
except Exception as error:
checks.append('FAILED: '+str(error))
finally:
diff --git a/scripts/check-panel-performance.py b/scripts/check-panel-performance.py
new file mode 100644
index 0000000..3490e0a
--- /dev/null
+++ b/scripts/check-panel-performance.py
@@ -0,0 +1,170 @@
+"""Exercise the real WPF panel with a synthetic model in an isolated process.
+
+Usage: python scripts/check-panel-performance.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR
+Requires pythonnet and psutil. Reports host-process memory separately from the
+standalone benchmark; Python/CLR test overhead is included here. Never forces GC.
+"""
+import ctypes
+import json
+import os
+from pathlib import Path
+import sys
+import time
+
+import psutil
+
+os.environ['FLEET_PANEL_OFFSCREEN'] = '1'
+exe, fixture, out = map(Path, sys.argv[1:4])
+out.mkdir(parents=True, exist_ok=True)
+cache = out / 'cache'
+cache.mkdir(exist_ok=True)
+model = json.loads(fixture.read_text())
+(cache/'panel.json').write_text(json.dumps(model))
+import clr
+for name in ('WindowsBase', 'PresentationCore', 'PresentationFramework'):
+ clr.AddReference(str(Path(r'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\WPF')/(name+'.dll')))
+from System import Activator, AppDomain, Array, Boolean, EventHandler, GC, Object, TimeSpan
+from System.Reflection import Assembly, BindingFlags
+from System.Threading import ApartmentState, Thread, ThreadStart
+from System.Windows import Application, ShutdownMode
+from System.Windows.Threading import Dispatcher, DispatcherFrame, DispatcherTimer
+
+assembly = Assembly.LoadFile(str(exe.resolve()))
+flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static
+process = psutil.Process()
+AppDomain.MonitoringIsEnabled = True
+samples, checks = [], []
+ctypes.windll.user32.GetGuiResources.argtypes = [ctypes.c_void_p, ctypes.c_uint]
+ctypes.windll.user32.GetGuiResources.restype = ctypes.c_uint
+ctypes.windll.kernel32.GetCurrentProcess.restype = ctypes.c_void_p
+
+
+def call(obj, name, *args):
+ method = next(m for m in obj.GetType().GetMethods(flags)
+ if m.Name == name and len(m.GetParameters()) == len(args))
+ return method.Invoke(obj, Array[Object](args))
+
+
+def field(obj, name):
+ return obj.GetType().GetField(name, flags).GetValue(obj)
+
+
+def pump(ms=40):
+ frame, timer = DispatcherFrame(), DispatcherTimer()
+ timer.Interval = TimeSpan.FromMilliseconds(ms)
+ def end(sender, event):
+ timer.Stop()
+ frame.Continue = False
+ handler = EventHandler(end)
+ timer.Tick += handler
+ timer.Start()
+ Dispatcher.PushFrame(frame)
+ timer.Tick -= handler
+
+
+def sample(cycle):
+ m = process.memory_full_info()
+ handle = ctypes.windll.kernel32.GetCurrentProcess()
+ samples.append(dict(cycle=cycle, private_resident=m.uss, working_set=m.rss,
+ private_commit=m.private, handles=process.num_handles(),
+ gdi=ctypes.windll.user32.GetGuiResources(handle, 0),
+ user=ctypes.windll.user32.GetGuiResources(handle, 1),
+ threads=process.num_threads(), managed_live=GC.GetTotalMemory(False),
+ allocated=AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize))
+
+
+def run():
+ app = None
+ try:
+ app = Application()
+ app.ShutdownMode = ShutdownMode.OnExplicitShutdown
+ parent = Activator.CreateInstance(assembly.GetType('FleetPanel.PanelWindow'), Array[Object]([str(cache.resolve())]))
+ call(parent, 'Start')
+ pump(1300)
+ view = field(parent, 'view')
+ content = field(view, 'content')
+ call(view, 'ShowDashboard')
+ parent.UpdateLayout()
+ original = content.Children[0]
+ start = AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize
+ clock = time.perf_counter()
+ reused = 0
+ for i in range(100):
+ model['ts'] += 1
+ (cache/'panel.json').write_text(json.dumps(model))
+ call(parent, 'LoadModel')
+ pump(5)
+ reused += int(content.Children[0] == original)
+ refresh = dict(count=100, reused=reused, elapsed_ms=(time.perf_counter()-clock)*1000,
+ allocated=AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize-start)
+ # A real value change must still replace the controls.
+ model['summary']['today'] += 1
+ (cache/'panel.json').write_text(json.dumps(model))
+ call(parent, 'LoadModel')
+ pump()
+ assert content.Children[0] != original
+ checks.append('changed content rebuilds the dashboard')
+ if assembly.GetType('FleetPanel.PanelData').GetField('ContentKey') is not None:
+ assert reused == 100
+ checks.append('100 timestamp-only refreshes retain existing controls')
+ saved = json.loads(json.dumps(model))
+ # Every meaningful status remains in the comparison, including the
+ # timestamps that describe the source's freshness and log coverage.
+ for section, key, value in [('coverage', 'updated', 42),
+ ('settings', 'cost', False),
+ ('summary', 'state', 'indexing')]:
+ previous = content.Children[0]
+ model[section][key] = value
+ (cache/'panel.json').write_text(json.dumps(model))
+ call(parent, 'LoadModel')
+ pump()
+ assert content.Children[0] != previous
+ previous = content.Children[0]
+ model['api_accounts'][0]['source'] = 'test-source'
+ (cache/'panel.json').write_text(json.dumps(model))
+ call(parent, 'LoadModel')
+ pump()
+ assert content.Children[0] != previous
+ checks.append('coverage, settings, summary and account status changes still rebuild')
+ model.clear()
+ model.update(saved)
+ (cache/'panel.json').write_text(json.dumps(model))
+ call(parent, 'LoadModel')
+ pump()
+ sample(0)
+ for i in range(1, 101):
+ call(parent, 'Open')
+ pump(360) # settle entrance, including starting the real glass worker
+ call(view, 'ShowDashboard')
+ parent.UpdateLayout()
+ call(view, 'ShowSettings')
+ parent.UpdateLayout()
+ call(view, 'ShowUsage')
+ parent.UpdateLayout()
+ call(parent, 'Close', Boolean(False))
+ pump(80)
+ assert not field(parent, 'open') and not field(parent, 'closing')
+ if i % 10 == 0:
+ sample(i)
+ (out/'progress.json').write_text(json.dumps(samples, indent=2))
+ checks.append('100 open/close and usage/dashboard/settings navigation cycles complete')
+ call(parent, 'Close') # Window.Close, not the private Close(bool)
+ pump(200)
+ sample(101)
+ assert not (cache/'panel-error.log').exists()
+ result = dict(checks=checks, refresh=refresh, samples=samples)
+ (out/'result.json').write_text(json.dumps(result, indent=2))
+ print(json.dumps(dict(checks=checks, refresh=refresh)), flush=True)
+ except Exception as error:
+ (out/'failure.txt').write_text(str(error))
+ print('FAILED:', error, flush=True)
+ finally:
+ if app is not None:
+ app.Shutdown()
+
+
+thread = Thread(ThreadStart(run))
+thread.SetApartmentState(ApartmentState.STA)
+thread.Start()
+thread.Join()
+sys.exit(1 if (out/'failure.txt').exists() else 0)
diff --git a/scripts/measure-panel.py b/scripts/measure-panel.py
new file mode 100644
index 0000000..43a98a1
--- /dev/null
+++ b/scripts/measure-panel.py
@@ -0,0 +1,59 @@
+"""Bounded, secret-free panel benchmark. Never uses the installed cache.
+
+Usage: python scripts/measure-panel.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR [RUNS]
+Requires psutil. All UI commands use Fleet's existing cache-file protocol.
+Private resident (USS), total working set, and private commit are separate.
+No heap collection, trimming, or changes to the system or installed Fleet.
+"""
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import time
+
+import psutil
+
+exe, fixture, out = map(Path, sys.argv[1:4])
+out.mkdir(parents=True, exist_ok=True)
+results = []
+
+
+def sample(process, phase, seconds=8):
+ cpu = process.cpu_times()
+ start = time.perf_counter()
+ points = []
+ for _ in range(seconds):
+ time.sleep(1)
+ m = process.memory_full_info()
+ points.append(dict(private_resident=m.uss, working_set=m.rss,
+ private_commit=m.private, handles=process.num_handles(),
+ threads=process.num_threads()))
+ end = process.cpu_times()
+ return dict(phase=phase, seconds=time.perf_counter()-start,
+ cpu_ms=1000*((end.user-cpu.user)+(end.system-cpu.system)), points=points)
+
+
+for run in range(int(sys.argv[4]) if len(sys.argv) > 4 else 3):
+ cache = out / str(run)
+ cache.mkdir(exist_ok=True)
+ (cache/'panel.json').write_bytes(fixture.read_bytes())
+ env = dict(os.environ, FLEET_PANEL_OFFSCREEN='1')
+ env.pop('FLEET_PARENT_PID', None)
+ child = subprocess.Popen([str(exe.resolve()), '--cache', str(cache.resolve())], env=env)
+ process = psutil.Process(child.pid)
+ try:
+ time.sleep(3)
+ phases = [sample(process, 'fresh-hidden')]
+ (cache/'panel.show').write_text('{}')
+ time.sleep(2)
+ phases.append(sample(process, 'open'))
+ (cache/'panel.show').write_text('{}')
+ time.sleep(2)
+ phases.append(sample(process, 'closed-after-open'))
+ results.append(dict(run=run, pid=child.pid, phases=phases))
+ finally:
+ (cache/'panel.quit').touch()
+ child.wait(timeout=10)
+ (out/'samples.json').write_text(json.dumps(results, indent=2))
+ print('completed run', run, flush=True)