From 9e0b00dd980909b6bda07f1b8292ec90b09601cb Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Sun, 9 Aug 2026 01:29:25 +0500 Subject: [PATCH] added a ban on running a second instance of the application. --- CursorLang/App.xaml.cs | 34 +++++++ .../Interop/ForegroundPermissionNative.cs | 26 +++++ CursorLang/Services/SingleInstanceGate.cs | 98 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 CursorLang/Interop/ForegroundPermissionNative.cs create mode 100644 CursorLang/Services/SingleInstanceGate.cs 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)); +}