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
14 changes: 14 additions & 0 deletions src/EDNexus.App/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public Bootstrap(SettingsStore store, AppSettings settings, CrashReporting crash
TwitchCleanup.Start();
Twitch = BuildTwitchAuth();

// The overlay's visibility rule must be in force before it is first shown.
Overlay.SetOnlyWhenGameFocused(Settings.Overlay.OnlyWhenGameFocused);

// Apply the saved voice choice up front so the very first callout already uses it.
Voice.SetVoice(Settings.Voice.VoiceName);
Voice.SetVolume(Settings.Voice.Volume);
Expand Down Expand Up @@ -219,6 +222,17 @@ public void ApplyMiningSpotAnnouncements(bool enabled)
Store.Save(Settings);
}

/// <summary>
/// Persist whether the overlay is shown only while Elite Dangerous (or EDNexus) is in front, and apply it
/// to the live window.
/// </summary>
public void ApplyOverlayOnlyWhenGameFocused(bool enabled)
{
Settings.Overlay.OnlyWhenGameFocused = enabled;
Store.Save(Settings);
Overlay.SetOnlyWhenGameFocused(enabled);
}

/// <summary>Persist the overlay's on/off state and show/hide the live window to match.</summary>
public void ApplyOverlayChoice(bool enabled)
{
Expand Down
134 changes: 124 additions & 10 deletions src/EDNexus.App/Services/Overlay/WindowsOverlay.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Threading;
Expand All @@ -8,14 +9,32 @@ namespace EDNexus.App.Services.Overlay;

/// <summary>
/// Windows overlay: a transparent, click-through, always-on-top Avalonia window drawn over Elite
/// Dangerous in borderless/windowed mode. Click-through is applied via the Win32 extended window
/// styles once the native handle exists, so the game keeps input focus and the overlay never
/// intercepts a click meant for the cockpit. All calls are best-effort: a failure here degrades to
/// Dangerous in borderless/windowed mode. Click-through and "never take focus" are applied via the Win32
/// extended window styles once the native handle exists, so the game keeps input focus and the overlay
/// never intercepts a click meant for the cockpit. All calls are best-effort: a failure here degrades to
/// "no overlay shown" rather than crashing the app.
/// </summary>
/// <remarks>
/// Because it is a topmost window, an overlay that is always up sits over every other app (EDCopilot's own
/// overlay included), and anything it does to its own window (a resize, a z-order change) is a desktop
/// event the game and other overlays can react to. So: the window has a fixed size (the panel grows inside
/// it, the native window never resizes on a jump); content is pushed only when it actually changed; the
/// window can never be activated; and by default it is visible only while Elite Dangerous is in front
/// (<see cref="OverlayVisibilityPolicy"/>).
/// </remarks>
public sealed class WindowsOverlay : IOverlay
{
private static readonly TimeSpan FocusPollInterval = TimeSpan.FromMilliseconds(750);

// All of the following are touched on the UI thread only.
private OverlayWindow? _window;
private DispatcherTimer? _focusTimer;
private bool _wanted;
private bool _onlyWhenGameFocused = true;
private OverlayContent? _lastContent;
private int _lastForegroundPid;
private string? _lastForegroundName;
private readonly string _ownProcess = Process.GetCurrentProcess().ProcessName;

public bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public bool IsVisible => _window?.IsVisible == true;
Expand All @@ -27,12 +46,18 @@ public void Show()
{
try
{
_wanted = true;
if (_window is null)
{
_window = new OverlayWindow();
_window.Opened += (_, _) => MakeClickThrough(_window);
// It is created non-activating and styled WS_EX_NOACTIVATE, so this should never fire. If it
// does, that is exactly the focus problem this window must not cause: leave a trace.
_window.Activated += (_, _) => Trace.TraceWarning("Overlay: the overlay window was activated (it should never take focus).");
}
_window.Show();

StartFocusTimer();
ApplyVisibility();
}
catch
{
Expand All @@ -46,28 +71,111 @@ public void Hide()
if (!IsSupported) return;
Dispatcher.UIThread.Post(() =>
{
try { _window?.Hide(); }
try
{
_wanted = false;
_focusTimer?.Stop();
ApplyVisibility();
}
catch { /* best-effort */ }
});
}

public void Update(OverlayContent content)
{
if (!IsSupported || _window is null) return;
if (!IsSupported) return;
Dispatcher.UIThread.Post(() =>
{
try { _window?.UpdateContent(content); }
try
{
if (_window is null) return;

// Called every UI tick (250 ms) with a freshly built snapshot. Writing identical text back
// and rebuilding the shortfall list each time churns layout for nothing; touch the window
// only when something changed.
if (_lastContent is not null && _lastContent.SameAs(content)) return;
_lastContent = content;
_window.UpdateContent(content);
}
catch { /* best-effort */ }
});
}

// --- Win32: WS_EX_LAYERED | WS_EX_TRANSPARENT makes the window click-through, and
// WS_EX_TOOLWINDOW keeps it out of the taskbar / alt-tab list alongside ShowInTaskbar="False". ---
public void SetOnlyWhenGameFocused(bool value)
{
if (!IsSupported) return;
Dispatcher.UIThread.Post(() =>
{
try
{
_onlyWhenGameFocused = value;
ApplyVisibility();
}
catch { /* best-effort */ }
});
}

private void StartFocusTimer()
{
_focusTimer ??= new DispatcherTimer { Interval = FocusPollInterval };
_focusTimer.Tick -= OnFocusTick;
_focusTimer.Tick += OnFocusTick;
_focusTimer.Start();
}

private void OnFocusTick(object? sender, EventArgs e)
{
try { ApplyVisibility(); }
catch { /* best-effort */ }
}

/// <summary>Shows or hides the window to match the policy. Does nothing when the state is already right.</summary>
private void ApplyVisibility()
{
var window = _window;
if (window is null) return;

var foreground = _wanted && _onlyWhenGameFocused ? ForegroundProcessName() : null;
var show = OverlayVisibilityPolicy.ShouldShow(_wanted, _onlyWhenGameFocused, foreground, _ownProcess);
if (show == window.IsVisible) return;

if (show) window.Show();
else window.Hide();
Trace.TraceInformation($"Overlay: {(show ? "shown" : "hidden")} (foreground: {foreground ?? "n/a"}).");
}

/// <summary>The process that owns the foreground window, or null when it cannot be determined.</summary>
private string? ForegroundProcessName()
{
try
{
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return null;

GetWindowThreadProcessId(hwnd, out var pid);
if (pid == 0) return null;
if ((int)pid == _lastForegroundPid) return _lastForegroundName;

using var process = Process.GetProcessById((int)pid);
_lastForegroundPid = (int)pid;
_lastForegroundName = process.ProcessName;
return _lastForegroundName;
}
catch
{
return null;
}
}

// --- Win32: WS_EX_LAYERED | WS_EX_TRANSPARENT makes the window click-through, WS_EX_TOOLWINDOW keeps it
// out of the taskbar / alt-tab list alongside ShowInTaskbar="False", and WS_EX_NOACTIVATE guarantees it
// can never become the active window (ShowActivated="False" only covers the first show). ---

private const int GwlExStyle = -20;
private const int WsExLayered = 0x80000;
private const int WsExTransparent = 0x20;
private const int WsExToolWindow = 0x80;
private const int WsExNoActivate = 0x08000000;

private static void MakeClickThrough(Window window)
{
Expand All @@ -76,7 +184,7 @@ private static void MakeClickThrough(Window window)
var handle = window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero) return;
var style = GetWindowLong(handle, GwlExStyle);
SetWindowLong(handle, GwlExStyle, style | WsExLayered | WsExTransparent | WsExToolWindow);
SetWindowLong(handle, GwlExStyle, style | WsExLayered | WsExTransparent | WsExToolWindow | WsExNoActivate);
}
catch
{
Expand All @@ -89,4 +197,10 @@ private static void MakeClickThrough(Window window)

[DllImport("user32.dll", SetLastError = true)]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);

[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();

[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
10 changes: 7 additions & 3 deletions src/EDNexus.App/Views/OverlayWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@
CanResize="False"
Topmost="True"
WindowStartupLocation="Manual"
SizeToContent="WidthAndHeight"
Width="360" Height="330"
Focusable="False">
<!-- Click-through is applied in code-behind (Win32 WS_EX_TRANSPARENT) once the native handle
exists, so the game underneath always keeps mouse/keyboard focus. -->
exists, so the game underneath always keeps mouse/keyboard focus.
The window has a FIXED size on purpose: it used to size itself to its content, so every jump (a new
system name, the "next jump" line appearing, a changed shortfall list) was a native resize of a
topmost window over the game, which the game and other overlays can react to. The visible panel is
the Border below, which grows and shrinks inside the transparent, click-through window instead. -->
<Border Background="{StaticResource OverlayScrim}" CornerRadius="8" Padding="14" BorderBrush="{StaticResource AmberBrush}" BorderThickness="1"
MinWidth="230">
MinWidth="230" HorizontalAlignment="Left" VerticalAlignment="Top">
<StackPanel Spacing="5">
<TextBlock Text="EDNEXUS" Foreground="{StaticResource AmberBrush}" FontSize="11" FontWeight="Bold" LetterSpacing="2" />
<TextBlock x:Name="SystemLine" Foreground="{StaticResource Ink}" FontSize="13" />
Expand Down
6 changes: 6 additions & 0 deletions src/EDNexus.App/Views/SettingsWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@
<CheckBox x:Name="OverlayToggle" Content="Show in-game overlay" Foreground="{StaticResource Ink}" />
<TextBlock Foreground="{StaticResource InkDim}" FontSize="12" TextWrapping="Wrap"
Text="A small, click-through, always-on-top panel over the game (borderless/windowed) showing your next jump, fuel, bio signals on the current body, and any colonisation shortfall. Windows only — a no-op on other platforms." />

<CheckBox x:Name="OverlayGameOnlyToggle" Content="Only show while Elite Dangerous is in front" Foreground="{StaticResource Ink}" />
<TextBlock Foreground="{StaticResource InkDim}" FontSize="12" TextWrapping="Wrap"
Text="Keeps the overlay off other apps, including other overlays such as EDCopilot. Turn this off to keep it always on top. If other windows are being minimised or losing focus on a system jump, leave this on or turn the overlay off." />
</StackPanel>
</Border>

Expand All @@ -198,6 +202,8 @@
<CheckBox x:Name="FuelLowToggle" Content="Fuel low" Foreground="{StaticResource Ink}" />
<CheckBox x:Name="ScanCompleteToggle" Content="Scan complete" Foreground="{StaticResource Ink}" />
<CheckBox x:Name="ShoppingListToggle" Content="Shopping-list item acquired" Foreground="{StaticResource Ink}" />
<TextBlock Foreground="{StaticResource InkDim}" FontSize="12" TextWrapping="Wrap"
Text="One more callout fires when you arrive in a system: known planetary mining spots. It is set under Mining (&quot;Call out known planetary mining spots on arrival&quot;) and also needs Speak callouts on." />

<Button x:Name="TestVoiceButton" Content="Test voice" Click="OnTestVoice"
Padding="10" HorizontalAlignment="Left" />
Expand Down
2 changes: 2 additions & 0 deletions src/EDNexus.App/Views/SettingsWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public SettingsWindow(Bootstrap boot, ViewModels.MainWindowViewModel? dashboard
MiningSpotsSummary.Text += $" {boot.Settings.Mining.KnownSpots.Count:N0} spots recorded so far.";

OverlayToggle.IsChecked = boot.Settings.Overlay.Enabled;
OverlayGameOnlyToggle.IsChecked = boot.Settings.Overlay.OnlyWhenGameFocused;

VoiceToggle.IsChecked = boot.Settings.Voice.Enabled;
VoiceNameCombo.ItemsSource = boot.Voice.AvailableVoices;
Expand Down Expand Up @@ -252,6 +253,7 @@ private async Task ApplyAsync(int miningThreshold)
_boot.ApplyShareDeliveries(RavenLookupToggle.IsChecked == true && ShareDeliveriesToggle.IsChecked == true);
_boot.ApplyMiningThreshold(miningThreshold);
_boot.ApplyMiningSpotAnnouncements(MiningSpotAnnounceToggle.IsChecked == true);
_boot.ApplyOverlayOnlyWhenGameFocused(OverlayGameOnlyToggle.IsChecked == true);
_boot.ApplyOverlayChoice(OverlayToggle.IsChecked == true);
_boot.ApplyVoiceChoice(
VoiceToggle.IsChecked == true,
Expand Down
6 changes: 6 additions & 0 deletions src/EDNexus.Core/Overlay/IOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ public interface IOverlay

/// <summary>Push a fresh content snapshot to the overlay. A no-op while hidden or unsupported.</summary>
void Update(OverlayContent content);

/// <summary>
/// Whether the overlay should be on screen only while Elite Dangerous (or EDNexus) is the app in front
/// (see <see cref="OverlayVisibilityPolicy"/>). Takes effect immediately; a no-op when unsupported.
/// </summary>
void SetOnlyWhenGameFocused(bool value);
}
1 change: 1 addition & 0 deletions src/EDNexus.Core/Overlay/NullOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public sealed class NullOverlay : IOverlay
public void Show() { }
public void Hide() { }
public void Update(OverlayContent content) { }
public void SetOnlyWhenGameFocused(bool value) { }
}
21 changes: 21 additions & 0 deletions src/EDNexus.Core/Overlay/OverlayModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,25 @@ public sealed record OverlayContent(

/// <summary>True when there is an active construction site with at least one outstanding commodity.</summary>
public bool HasColonisationShortfall => ColonisationShortfalls.Count > 0;

/// <summary>
/// Whether <paramref name="other"/> would draw exactly the same overlay: every field equal, with the
/// shortfall lines compared by value. The built-in record equality compares that list by reference,
/// and a fresh list is built every tick, so it cannot tell an unchanged snapshot from a changed one.
/// The overlay uses this to skip redundant updates, so an idle overlay touches nothing.
/// </summary>
public bool SameAs(OverlayContent? other)
{
if (ReferenceEquals(this, other)) return true;
if (other is null) return false;

return StarSystem == other.StarSystem
&& NextJumpSystem == other.NextJumpSystem
&& FuelMain.Equals(other.FuelMain)
&& FuelCapacity.Equals(other.FuelCapacity)
&& BioSignalBody == other.BioSignalBody
&& BioSignalCount == other.BioSignalCount
&& BioSignalDetail == other.BioSignalDetail
&& ColonisationShortfalls.SequenceEqual(other.ColonisationShortfalls);
}
}
42 changes: 42 additions & 0 deletions src/EDNexus.Core/Overlay/OverlayVisibilityPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace EDNexus.Core.Overlay;

/// <summary>
/// When the overlay should actually be on screen. The overlay is a topmost window, so left on all the
/// time it sits over every other app, including other overlays such as EDCopilot's. By default it is
/// shown only while Elite Dangerous (or EDNexus itself, so the Settings preview works) is the app in
/// front. Pure so the rule can be tested without a window.
/// </summary>
public static class OverlayVisibilityPolicy
{
/// <summary>Process names (without <c>.exe</c>) of the game client.</summary>
public static readonly IReadOnlyList<string> GameProcessNames = ["EliteDangerous64", "EliteDangerous"];

/// <summary>Whether the overlay window should be visible right now.</summary>
/// <param name="wanted">The commander has the overlay switched on.</param>
/// <param name="onlyWhenGameFocused">The "only while Elite is in front" setting.</param>
/// <param name="foregroundProcess">
/// Name of the process that owns the foreground window, or null when it cannot be determined. Unknown
/// fails open (visible), so a detection problem never leaves the overlay permanently hidden.
/// </param>
/// <param name="ownProcess">EDNexus's own process name.</param>
public static bool ShouldShow(bool wanted, bool onlyWhenGameFocused, string? foregroundProcess, string? ownProcess)
{
if (!wanted) return false;
if (!onlyWhenGameFocused) return true;
if (string.IsNullOrWhiteSpace(foregroundProcess)) return true;

var name = Normalise(foregroundProcess);
if (ownProcess is not null && string.Equals(name, Normalise(ownProcess), StringComparison.OrdinalIgnoreCase))
return true;

foreach (var game in GameProcessNames)
{
if (string.Equals(name, game, StringComparison.OrdinalIgnoreCase)) return true;
}

return false;
}

private static string Normalise(string processName)
=> processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? processName[..^4] : processName;
}
7 changes: 7 additions & 0 deletions src/EDNexus.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ public sealed class OverlaySettings
{
/// <summary>When true, the overlay window is shown over the game. Default off.</summary>
public bool Enabled { get; set; }

/// <summary>
/// When true (the default), the overlay is shown only while Elite Dangerous (or EDNexus itself) is the
/// app in front, instead of sitting on top of every other window, including other overlays such as
/// EDCopilot's. Turn off to keep it always on top.
/// </summary>
public bool OnlyWhenGameFocused { get; set; } = true;
}

/// <summary>
Expand Down
Loading
Loading