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));
+ }
+}