diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs
index 608f4d9..42fe88a 100644
--- a/CursorLang/App.xaml.cs
+++ b/CursorLang/App.xaml.cs
@@ -13,11 +13,23 @@ namespace CursorLang;
public partial class App : Application
{
private ServiceProvider? _services;
+ private SingleInstanceGate? _instanceGate;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
+ var gate = new SingleInstanceGate();
+ if (!gate.TryAcquire())
+ {
+ gate.Dispose();
+ Shutdown();
+ return;
+ }
+
+ _instanceGate = gate;
+ _instanceGate.ActivationRequested += OnActivationRequested;
+
var services = new ServiceCollection();
ConfigureServices(services);
_services = services.BuildServiceProvider();
@@ -34,9 +46,31 @@ public partial class App : Application
protected override void OnExit(ExitEventArgs e)
{
_services?.Dispose();
+
+ if (_instanceGate is not null)
+ {
+ _instanceGate.ActivationRequested -= OnActivationRequested;
+ _instanceGate.Dispose();
+ }
+
base.OnExit(e);
}
+ private void OnActivationRequested(object? sender, EventArgs e)
+ {
+ if (MainWindow is not { IsVisible: true })
+ {
+ return;
+ }
+
+ if (MainWindow.WindowState == WindowState.Minimized)
+ {
+ MainWindow.WindowState = WindowState.Normal;
+ }
+
+ MainWindow.Activate();
+ }
+
private static void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(new KeyboardLayoutOptions());
diff --git a/CursorLang/Interop/ForegroundPermissionNative.cs b/CursorLang/Interop/ForegroundPermissionNative.cs
new file mode 100644
index 0000000..a3a33b7
--- /dev/null
+++ b/CursorLang/Interop/ForegroundPermissionNative.cs
@@ -0,0 +1,26 @@
+using System.Runtime.InteropServices;
+
+namespace CursorLang.Interop;
+
+///
+/// Право выводить окно на передний план.
+///
+internal static class ForegroundPermissionNative
+{
+ /// ASFW_ANY — право получает любой процесс.
+ private const uint AnyProcess = 0xFFFFFFFF;
+
+ ///
+ /// Уступает своё право вывести окно на передний план другим процессам.
+ ///
+ ///
+ /// Поменять окно переднего плана Windows разрешает не всякому: нужно быть
+ /// процессом, с которым пользователь работал последним. Давно запущенный
+ /// экземпляр приложения к таким не относится, и его окно всплывёт только
+ /// если правом поделится процесс, который пользователь запустил только что.
+ ///
+ internal static void GrantToAnyProcess() => AllowSetForegroundWindow(AnyProcess);
+
+ [DllImport("user32.dll")]
+ private static extern bool AllowSetForegroundWindow(uint dwProcessId);
+}
diff --git a/CursorLang/Services/SingleInstanceGate.cs b/CursorLang/Services/SingleInstanceGate.cs
new file mode 100644
index 0000000..1f11749
--- /dev/null
+++ b/CursorLang/Services/SingleInstanceGate.cs
@@ -0,0 +1,98 @@
+using System.Windows.Threading;
+using CursorLang.Interop;
+
+namespace CursorLang.Services;
+
+///
+/// Пускает работать только один экземпляр приложения: повторный запуск не
+/// поднимает второе окно, а показывает окно уже работающего.
+///
+///
+/// Имена объектов ядра оставлены без префикса Global, то есть живут в пространстве
+/// имён сеанса. Один экземпляр на всю машину дал бы при быстром переключении
+/// пользователей странную картину: второй пользователь остался бы без приложения,
+/// а показать ему окно первого всё равно нельзя — окна принадлежат сеансу.
+///
+public sealed class SingleInstanceGate : IDisposable
+{
+ private const string MutexName = "CursorLang.SingleInstance";
+ private const string ActivationEventName = "CursorLang.ActivationRequest";
+
+ private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
+
+ private Mutex? _mutex;
+ private EventWaitHandle? _activationRequest;
+ private RegisteredWaitHandle? _activationWait;
+ private bool _isOwner;
+
+ /// Другой запуск просит показать окно.
+ public event EventHandler? ActivationRequested;
+
+ ///
+ /// Занимает место единственного экземпляра. Если приложение уже работает,
+ /// просит его показаться и возвращает false — вызвавшему остаётся выйти.
+ ///
+ public bool TryAcquire()
+ {
+ _mutex = new Mutex(initiallyOwned: false, MutexName);
+
+ try
+ {
+ _isOwner = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
+ }
+ catch (AbandonedMutexException)
+ {
+ // Предыдущий экземпляр завершился аварийно и мьютекс не отпустил.
+ // Владельца у него теперь нет, а значит место свободно
+ _isOwner = true;
+ }
+
+ // Событие открывают оба экземпляра: первый — чтобы ждать просьбы,
+ // второй — чтобы её подать. Кто из них создаст объект, зависит от того,
+ // кто оказался первым, и на работу не влияет
+ _activationRequest = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
+
+ if (!_isOwner)
+ {
+ ForegroundPermissionNative.GrantToAnyProcess();
+ _activationRequest.Set();
+ return false;
+ }
+
+ // Ожидание отдано пулу потоков: держать ради него свой поток не за чем,
+ // а просьба может не прийти никогда
+ _activationWait = ThreadPool.RegisterWaitForSingleObject(
+ _activationRequest,
+ OnActivationSignalled,
+ state: null,
+ Timeout.Infinite,
+ executeOnlyOnce: false);
+
+ return true;
+ }
+
+ public void Dispose()
+ {
+ _activationWait?.Unregister(null);
+ _activationWait = null;
+
+ _activationRequest?.Dispose();
+ _activationRequest = null;
+
+ // Мьютекс отпускает тот же поток, что его занял: и то и другое
+ // происходит на потоке пользовательского интерфейса
+ if (_isOwner)
+ {
+ _mutex?.ReleaseMutex();
+ _isOwner = false;
+ }
+
+ _mutex?.Dispose();
+ _mutex = null;
+ }
+
+ // Пул потоков сообщает о просьбе где придётся, а окно слушается только
+ // своего потока
+ private void OnActivationSignalled(object? state, bool timedOut) =>
+ _dispatcher.BeginInvoke(() => ActivationRequested?.Invoke(this, EventArgs.Empty));
+}