Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace OrthoStreamXP.Gui;

/// <summary>
/// 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.
/// </summary>
internal sealed class XPlaneWatch
{
private bool _seenRunning;

/// <summary>Notes whether X-Plane runs now; true when it ran at the last check and no longer does.</summary>
public bool QuitSince(bool runningNow)
{
bool quit = _seenRunning && !runningNow;
_seenRunning = runningNow;
return quit;
}
}
22 changes: 22 additions & 0 deletions src/OrthoStreamXP/Gui/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Windows;
using OrthoStreamXP.Setup;

namespace OrthoStreamXP.Gui;

Expand All @@ -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();
};
}

/// <summary>
/// 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.
/// </summary>
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();
}

/// <summary>Closing while the scenery is mounted asks first, then unmounts before the window goes.</summary>
Expand Down
78 changes: 78 additions & 0 deletions src/OrthoStreamXP/Gui/TrayIcon.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Windows;
using Forms = System.Windows.Forms;

namespace OrthoStreamXP.Gui;

/// <summary>
/// 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.
/// </summary>
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();
}

/// <summary>Whether the icon is in the notification area now.</summary>
public bool IsShown => _icon.Visible;

/// <summary>Hides the window from the screen and the taskbar, leaving the icon in the notification area.</summary>
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);
}
}
9 changes: 9 additions & 0 deletions src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ public HomeViewModel(MainViewModel main)
/// <summary>Stopped: start OrthoStreamXP, then X-Plane. Running: start X-Plane.</summary>
public ICommand Fly { get; }

/// <summary>The Fly button started X-Plane: the window gets out of its way.</summary>
public event EventHandler? XPlaneStarted;

/// <summary>X-Plane was running and has quit (checked every few seconds while OrthoStreamXP runs).</summary>
public event EventHandler? XPlaneQuit;

/// <summary>X-Plane is installed where the settings say, and not running: one button starts it (and OrthoStreamXP first).</summary>
public bool ShowFly => Mount.State is MountState.Stopped or MountState.Running && _xplaneProgram is not null && !_xplaneRunning;

Expand Down Expand Up @@ -279,12 +285,14 @@ public void OnShown()

private string? _xplaneProgram;
private bool _xplaneRunning;
private readonly XPlaneWatch _xplaneWatch = new();

/// <summary>Notes where X-Plane is installed and whether it runs (on start-up and every few seconds).</summary>
private void CheckXPlane()
{
_xplaneProgram = SystemCheck.XPlaneProgram(SettingsDocument.Load(SettingsDocument.DefaultPath).Get("xplane"));
_xplaneRunning = SystemCheck.IsXPlaneRunning();
if (_xplaneWatch.QuitSince(_xplaneRunning)) XPlaneQuit?.Invoke(this, EventArgs.Empty);
}

/// <summary>OrthoStreamXP first (X-Plane looks for scenery only when it starts), then X-Plane.</summary>
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/OrthoStreamXP/OrthoStreamXP.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<ApplicationIcon>orthostreamxp.ico</ApplicationIcon>
<!-- The program is the window; app.manifest keeps Explorer from opening a console. -->
<UseWPF>true</UseWPF>
<!-- Only for the notification-area icon (NotifyIcon), which WPF lacks; same Windows Desktop runtime as WPF. -->
<UseWindowsForms>true</UseWindowsForms>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- WPF's Windows 11 (Fluent) theme is marked experimental. -->
<NoWarn>$(NoWarn);WPF0001</NoWarn>
Expand Down Expand Up @@ -43,6 +45,9 @@
<!-- WPF projects leave these out of the implicit usings; the engine uses them everywhere. -->
<Using Include="System.IO" />
<Using Include="System.Net.Http" />
<!-- Windows Forms is used by name only (Gui\TrayIcon.cs): its types would clash with WPF's (Application, MessageBox, Point...). -->
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
</ItemGroup>

<ItemGroup>
Expand Down
139 changes: 139 additions & 0 deletions src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace OrthoStreamXP.Setup;

/// <summary>
/// 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.
/// </summary>
internal static partial class XPlaneWindow
{
private const int SwRestore = 9;
private static readonly TimeSpan CheckEvery = TimeSpan.FromMilliseconds(250);

/// <summary>
/// Hands the front to X-Plane's window whenever this program has it, until X-Plane's window has stayed in front for
/// <paramref name="settle"/>, someone chose another program, X-Plane quit, or <paramref name="timeout"/> passed.
/// Call it while this program's window is in front, and hide that window only after it returns.
/// </summary>
/// <returns>True when X-Plane's window settled in front.</returns>
public static async Task<bool> 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);
}
43 changes: 43 additions & 0 deletions tests/OrthoStreamXP.Tests/TrayIconTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Windows;
using OrthoStreamXP.Gui;

namespace OrthoStreamXP.Tests;

/// <summary>The notification-area icon is there exactly while the window is hidden.</summary>
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);
}
}
Loading
Loading