From 40f8d161a1effabac879b79c6efded3eb9796496 Mon Sep 17 00:00:00 2001 From: flysimdev Date: Thu, 12 Nov 2026 13:59:37 +0100 Subject: [PATCH] Added automatic hiding the OrthoStream app to tray --- src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs | 18 +++ src/OrthoStreamXP/Gui/MainWindow.xaml.cs | 22 +++ src/OrthoStreamXP/Gui/TrayIcon.cs | 78 ++++++++++ .../Gui/ViewModels/HomeViewModel.cs | 9 ++ src/OrthoStreamXP/OrthoStreamXP.csproj | 5 + .../Setup/Installation/XPlaneWindow.cs | 139 ++++++++++++++++++ tests/OrthoStreamXP.Tests/TrayIconTests.cs | 43 ++++++ tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs | 37 +++++ 8 files changed, 351 insertions(+) create mode 100644 src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs create mode 100644 src/OrthoStreamXP/Gui/TrayIcon.cs create mode 100644 src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs create mode 100644 tests/OrthoStreamXP.Tests/TrayIconTests.cs create mode 100644 tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs diff --git a/src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs b/src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs new file mode 100644 index 0000000..3e7f937 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs @@ -0,0 +1,18 @@ +namespace OrthoStreamXP.Gui; + +/// +/// Tells from the process list's answers, one check at a time, when X-Plane quits. Only what a check saw counts: an +/// X-Plane just started may not be in the process list at the next check yet, and that is not a quit. +/// +internal sealed class XPlaneWatch +{ + private bool _seenRunning; + + /// Notes whether X-Plane runs now; true when it ran at the last check and no longer does. + public bool QuitSince(bool runningNow) + { + bool quit = _seenRunning && !runningNow; + _seenRunning = runningNow; + return quit; + } +} diff --git a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs index dfcc866..c8b49da 100644 --- a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs +++ b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Windows; +using OrthoStreamXP.Setup; namespace OrthoStreamXP.Gui; @@ -13,6 +14,27 @@ public MainWindow(MainViewModel main) _main = main; DataContext = main; InitializeComponent(); + var tray = new TrayIcon(this); + _main.Home.XPlaneStarted += async (_, _) => await HandOverToXPlaneAsync(tray).ConfigureAwait(true); + // The flight is over: back on the Home page, with Stop and (once stopped) the flight's report. + _main.Home.XPlaneQuit += (_, _) => + { + if (IsVisible) return; + _main.Show(_main.Home); + tray.ShowWindow(); + }; + } + + /// + /// X-Plane was started: once its window has the front (given by this window, which has it), this window goes to the + /// notification area, out of its way. Not before: hidden while it still has the front, the front would go to the + /// window behind it instead of X-Plane's. + /// + private async Task HandOverToXPlaneAsync(TrayIcon tray) + { + await XPlaneWindow.HandOverAsync(settle: TimeSpan.FromSeconds(1), timeout: TimeSpan.FromMinutes(3)).ConfigureAwait(true); + // X-Plane quit meanwhile: the window stays, as it would come back for the quit anyway. + if (IsVisible && SystemCheck.IsXPlaneRunning()) tray.HideWindow(); } /// Closing while the scenery is mounted asks first, then unmounts before the window goes. diff --git a/src/OrthoStreamXP/Gui/TrayIcon.cs b/src/OrthoStreamXP/Gui/TrayIcon.cs new file mode 100644 index 0000000..1c111d1 --- /dev/null +++ b/src/OrthoStreamXP/Gui/TrayIcon.cs @@ -0,0 +1,78 @@ +using System.Windows; +using Forms = System.Windows.Forms; + +namespace OrthoStreamXP.Gui; + +/// +/// The window's icon in the notification area, shown while the window is hidden (such as after it started X-Plane, so it +/// does not stay in front of it). A click on it shows the window again. It goes when the window closes. +/// +internal sealed class TrayIcon : IDisposable +{ + private readonly Window _window; + private readonly Forms.NotifyIcon _icon; + private readonly Forms.ContextMenuStrip _menu = new(); + private readonly System.Drawing.Icon _picture = LoadIcon(); + private bool _disposed; + + public TrayIcon(Window window) + { + _window = window; + _menu.Items.Add("Open OrthoStreamXP", null, (_, _) => ShowWindow()); + // Through the window, so closing while the scenery is mounted still asks first. + _menu.Items.Add("Exit", null, (_, _) => + { + ShowWindow(); + _window.Close(); + }); + _icon = new Forms.NotifyIcon { Text = "OrthoStreamXP", Icon = _picture, ContextMenuStrip = _menu }; + _icon.MouseClick += (_, e) => + { + if (e.Button == Forms.MouseButtons.Left) ShowWindow(); + }; + // Shown again by any means (the icon, or a second start of the program): the icon goes. + _window.IsVisibleChanged += (_, _) => + { + if (_window.IsVisible) _icon.Visible = false; + }; + _window.Closed += (_, _) => Dispose(); + } + + /// Whether the icon is in the notification area now. + public bool IsShown => _icon.Visible; + + /// Hides the window from the screen and the taskbar, leaving the icon in the notification area. + public void HideWindow() + { + _icon.Visible = true; + _window.Hide(); + } + + public void ShowWindow() + { + _window.Show(); + if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal; + // Shown on its own (X-Plane quit), this program may not take the front from the window that got it, such as + // Explorer's: on top of all for a moment, it still comes above that window. + _window.Topmost = true; + _window.Topmost = false; + _window.Activate(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // Taken off first: otherwise it stays in the notification area until the mouse passes over it. + _icon.Visible = false; + _icon.Dispose(); + _menu.Dispose(); + _picture.Dispose(); + } + + private static System.Drawing.Icon LoadIcon() + { + using Stream stream = Application.GetResourceStream(new Uri("pack://application:,,,/orthostreamxp;component/orthostreamxp.ico")).Stream; + return new System.Drawing.Icon(stream, Forms.SystemInformation.SmallIconSize); + } +} diff --git a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs index bbcb12c..d7b1c8e 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs @@ -85,6 +85,12 @@ public HomeViewModel(MainViewModel main) /// Stopped: start OrthoStreamXP, then X-Plane. Running: start X-Plane. public ICommand Fly { get; } + /// The Fly button started X-Plane: the window gets out of its way. + public event EventHandler? XPlaneStarted; + + /// X-Plane was running and has quit (checked every few seconds while OrthoStreamXP runs). + public event EventHandler? XPlaneQuit; + /// X-Plane is installed where the settings say, and not running: one button starts it (and OrthoStreamXP first). public bool ShowFly => Mount.State is MountState.Stopped or MountState.Running && _xplaneProgram is not null && !_xplaneRunning; @@ -279,12 +285,14 @@ public void OnShown() private string? _xplaneProgram; private bool _xplaneRunning; + private readonly XPlaneWatch _xplaneWatch = new(); /// Notes where X-Plane is installed and whether it runs (on start-up and every few seconds). private void CheckXPlane() { _xplaneProgram = SystemCheck.XPlaneProgram(SettingsDocument.Load(SettingsDocument.DefaultPath).Get("xplane")); _xplaneRunning = SystemCheck.IsXPlaneRunning(); + if (_xplaneWatch.QuitSince(_xplaneRunning)) XPlaneQuit?.Invoke(this, EventArgs.Empty); } /// OrthoStreamXP first (X-Plane looks for scenery only when it starts), then X-Plane. @@ -318,6 +326,7 @@ private async Task FlyAsync() _xplaneProblem = new Notice(Severity.Error, "X-Plane could not be started", $"{ex.Message} Start it yourself; the photo scenery is ready for it."); } Update(); + if (_xplaneProblem is null) XPlaneStarted?.Invoke(this, EventArgs.Empty); } private Notice? _xplaneProblem; diff --git a/src/OrthoStreamXP/OrthoStreamXP.csproj b/src/OrthoStreamXP/OrthoStreamXP.csproj index c4d9434..b252560 100644 --- a/src/OrthoStreamXP/OrthoStreamXP.csproj +++ b/src/OrthoStreamXP/OrthoStreamXP.csproj @@ -11,6 +11,8 @@ orthostreamxp.ico true + + true app.manifest $(NoWarn);WPF0001 @@ -43,6 +45,9 @@ + + + diff --git a/src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs b/src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs new file mode 100644 index 0000000..b1a76dd --- /dev/null +++ b/src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace OrthoStreamXP.Setup; + +/// +/// Gives X-Plane's window the front while it starts. Started through Explorer, X-Plane does not bring itself to the front +/// (Windows lets only the program in front, here OrthoStreamXP, decide which window comes next). Windows hands the front +/// over a moment after it is asked: until X-Plane has it, OrthoStreamXP's window must stay, or the front goes to the +/// window behind it. +/// +internal static partial class XPlaneWindow +{ + private const int SwRestore = 9; + private static readonly TimeSpan CheckEvery = TimeSpan.FromMilliseconds(250); + + /// + /// Hands the front to X-Plane's window whenever this program has it, until X-Plane's window has stayed in front for + /// , someone chose another program, X-Plane quit, or passed. + /// Call it while this program's window is in front, and hide that window only after it returns. + /// + /// True when X-Plane's window settled in front. + public static async Task HandOverAsync(TimeSpan settle, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var waited = Stopwatch.StartNew(); + nint shown = 0; + TimeSpan? inFrontSince = null; + bool seen = false; + while (waited.Elapsed < timeout) + { + if (Find() is not { } xplane) + { + if (seen) + { + Log.Info("X-Plane quit while it started"); + return false; + } + } + else + { + seen = true; + uint inFront = ProcessOf(GetForegroundWindow()); + if (inFront == 0) + { + // No window in front for a moment, as while a window is replaced: look again. + } + else if (inFront == xplane.ProcessId) + { + inFrontSince ??= waited.Elapsed; + if (waited.Elapsed - inFrontSince >= settle) + { + Log.Info($"X-Plane's window is in front (after {waited.Elapsed.TotalSeconds:0} s)"); + return true; + } + } + else if (inFront == (uint)Environment.ProcessId) + { + inFrontSince = null; + if (IsIconic(xplane.Window)) ShowWindow(xplane.Window, SwRestore); + bool given = SetForegroundWindow(xplane.Window); + if (xplane.Window != shown) + Log.Info($"X-Plane's window \"{xplane.Title}\" (0x{xplane.Window:X}) {(given ? "brought to the front" : "was not let to the front by Windows")} after {waited.Elapsed.TotalSeconds:0.0} s"); + shown = xplane.Window; + } + else + { + // Another program is in front: chosen by the person, so X-Plane is not pushed over it. + Log.Info($"{ProgramName(inFront)} came to the front while X-Plane started; X-Plane's window is left where it is"); + return false; + } + } + await Task.Delay(CheckEvery, cancellationToken).ConfigureAwait(true); + } + Log.Info($"X-Plane's window did not settle in front within {timeout.TotalSeconds:0} s"); + return false; + } + + private static (uint ProcessId, nint Window, string Title)? Find() + { + Process[] running = Process.GetProcessesByName("X-Plane"); + try + { + foreach (Process process in running) + { + try + { + if (process.MainWindowHandle != 0) return ((uint)process.Id, process.MainWindowHandle, process.MainWindowTitle); + } + catch (InvalidOperationException) + { + // It quit meanwhile. + } + } + return null; + } + finally + { + foreach (Process process in running) process.Dispose(); + } + } + + private static string ProgramName(uint processId) + { + try + { + using Process process = Process.GetProcessById((int)processId); + return process.ProcessName; + } + catch (ArgumentException) + { + return "Another program"; // it quit meanwhile + } + } + + private static uint ProcessOf(nint window) + { + if (window == 0) return 0; + _ = GetWindowThreadProcessId(window, out uint processId); + return processId; + } + + [LibraryImport("user32.dll")] + private static partial nint GetForegroundWindow(); + + [LibraryImport("user32.dll")] + private static partial uint GetWindowThreadProcessId(nint window, out uint processId); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetForegroundWindow(nint window); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool IsIconic(nint window); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindow(nint window, int command); +} diff --git a/tests/OrthoStreamXP.Tests/TrayIconTests.cs b/tests/OrthoStreamXP.Tests/TrayIconTests.cs new file mode 100644 index 0000000..3903b24 --- /dev/null +++ b/tests/OrthoStreamXP.Tests/TrayIconTests.cs @@ -0,0 +1,43 @@ +using System.Windows; +using OrthoStreamXP.Gui; + +namespace OrthoStreamXP.Tests; + +/// The notification-area icon is there exactly while the window is hidden. +public sealed class TrayIconTests +{ + [Fact] + public void Icon_shows_while_the_window_is_hidden_and_goes_when_it_is_shown_again() + { + var seen = new List<(bool WindowVisible, bool IconShown)>(); + Exception? error = null; + var thread = new Thread(() => + { + try + { + var window = new Window { Width = 200, Height = 100, ShowActivated = false }; + using var tray = new TrayIcon(window); + window.Show(); + seen.Add((window.IsVisible, tray.IsShown)); + tray.HideWindow(); + seen.Add((window.IsVisible, tray.IsShown)); + tray.ShowWindow(); + seen.Add((window.IsVisible, tray.IsShown)); + // Shown by other means (a second start of the program): the icon goes too. + tray.HideWindow(); + window.Show(); + seen.Add((window.IsVisible, tray.IsShown)); + window.Close(); + } + catch (Exception ex) + { + error = ex; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + Assert.Null(error); + Assert.Equal([(true, false), (false, true), (true, false), (true, false)], seen); + } +} diff --git a/tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs b/tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs new file mode 100644 index 0000000..07a8b17 --- /dev/null +++ b/tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs @@ -0,0 +1,37 @@ +using OrthoStreamXP.Gui; + +namespace OrthoStreamXP.Tests; + +public sealed class XPlaneWatchTests +{ + [Fact] + public void Quit_is_when_a_check_no_longer_sees_X_Plane_that_the_last_one_saw() + { + var watch = new XPlaneWatch(); + Assert.False(watch.QuitSince(true)); + Assert.False(watch.QuitSince(true)); + Assert.True(watch.QuitSince(false)); + // Reported once, not at every check after. + Assert.False(watch.QuitSince(false)); + } + + [Fact] + public void Not_running_yet_is_not_a_quit() + { + // Just started from the Fly button: the first checks may not see it in the process list yet. + var watch = new XPlaneWatch(); + Assert.False(watch.QuitSince(false)); + Assert.False(watch.QuitSince(false)); + Assert.False(watch.QuitSince(true)); + } + + [Fact] + public void A_second_flight_is_watched_too() + { + var watch = new XPlaneWatch(); + watch.QuitSince(true); + Assert.True(watch.QuitSince(false)); + Assert.False(watch.QuitSince(true)); + Assert.True(watch.QuitSince(false)); + } +}