From 484941c7adbbe4dae04ef1a569f8a7e19fd239f5 Mon Sep 17 00:00:00 2001 From: Ben Olden-Cooligan Date: Sat, 30 Mar 2024 13:00:25 -0700 Subject: [PATCH] Rework cross-process coordination and support "Open With" #146 --- NAPS2.App.Tests/GuiAppTests.cs | 5 +- .../WinForms/DesktopControllerTests.cs | 30 +++- .../Windows/WindowsApplicationLifecycle.cs | 46 ++++-- .../EtoForms/Desktop/DesktopController.cs | 123 ++++++++++----- NAPS2.Lib/EtoForms/EtoOperationProgress.cs | 8 - .../Notifications/NotificationManager.cs | 5 +- .../Notifications/ProgressNotificationView.cs | 6 +- NAPS2.Lib/EtoForms/Ui/ProgressForm.cs | 5 + NAPS2.Lib/Modules/CommonModule.cs | 2 + NAPS2.Lib/NAPS2.Lib.csproj | 3 + NAPS2.Lib/Remoting/Pipes.cs | 146 ------------------ NAPS2.Lib/Remoting/ProcessCoordinator.cs | 105 +++++++++++++ .../Remoting/ProcessCoordinatorService.proto | 26 ++++ 13 files changed, 296 insertions(+), 214 deletions(-) delete mode 100644 NAPS2.Lib/Remoting/Pipes.cs create mode 100644 NAPS2.Lib/Remoting/ProcessCoordinator.cs create mode 100644 NAPS2.Lib/Remoting/ProcessCoordinatorService.proto diff --git a/NAPS2.App.Tests/GuiAppTests.cs b/NAPS2.App.Tests/GuiAppTests.cs index 0e38ac3898..42b4baf1e4 100644 --- a/NAPS2.App.Tests/GuiAppTests.cs +++ b/NAPS2.App.Tests/GuiAppTests.cs @@ -1,4 +1,3 @@ -using System.Threading; using NAPS2.App.Tests.Targets; using NAPS2.Remoting; using NAPS2.Sdk.Tests; @@ -23,8 +22,8 @@ public void CreatesWindow(IAppTestTarget target) } else { - Thread.Sleep(1000); - Assert.True(Pipes.SendMessage(process, Pipes.MSG_CLOSE_WINDOW)); + var helper = ProcessCoordinator.CreateDefault(); + Assert.True(helper.CloseWindow(process, 1000)); } Assert.True(process.WaitForExit(5000)); AppTestHelper.AssertNoErrorLog(FolderPath); diff --git a/NAPS2.Lib.Tests/WinForms/DesktopControllerTests.cs b/NAPS2.Lib.Tests/WinForms/DesktopControllerTests.cs index fc5714f138..646eb0a24a 100644 --- a/NAPS2.Lib.Tests/WinForms/DesktopControllerTests.cs +++ b/NAPS2.Lib.Tests/WinForms/DesktopControllerTests.cs @@ -2,9 +2,9 @@ using NAPS2.EtoForms.Desktop; using NAPS2.EtoForms.Notifications; using NAPS2.ImportExport; -using NAPS2.ImportExport.Images; using NAPS2.Platform.Windows; using NAPS2.Recovery; +using NAPS2.Remoting; using NAPS2.Remoting.Server; using NAPS2.Remoting.Worker; using NAPS2.Sdk.Tests; @@ -39,6 +39,7 @@ public class DesktopControllerTests : ContextualTests private readonly IScannedImagePrinter _scannedImagePrinter; private readonly ThumbnailController _thumbnailController; private readonly ISharedDeviceManager _sharedDeviceManager; + private readonly ProcessCoordinator _processCoordinator; public DesktopControllerTests() { @@ -61,6 +62,8 @@ public DesktopControllerTests() _scannedImagePrinter = Substitute.For(); _thumbnailController = new ThumbnailController(_thumbnailRenderQueue, _config); _sharedDeviceManager = Substitute.For(); + _processCoordinator = + new ProcessCoordinator(Path.Combine(FolderPath, "instance.lock"), Guid.NewGuid().ToString("D")); ScanningContext.WorkerFactory = Substitute.For(); _desktopController = new DesktopController( ScanningContext, @@ -82,6 +85,7 @@ public DesktopControllerTests() _desktopFormProvider, _scannedImagePrinter, _sharedDeviceManager, + _processCoordinator, new RecoveryManager(ScanningContext) ); @@ -262,4 +266,28 @@ public async Task Initialize_WithOldUpdateCheck_NotifiesOfUpdate() _updateChecker.ReceivedCallsCount(1); _notify.ReceivedCallsCount(1); } + + [Fact] + public async Task ProcessCoordinatorOpenFile() + { + var importOp = new ImportOperation(new FileImporter(ScanningContext)); + _operationFactory.Create().Returns(importOp); + var path = CopyResourceToFile(ImageResources.dog, "test.jpg"); + + await _desktopController.Initialize(); + Assert.True(_processCoordinator.OpenFile(Process.GetCurrentProcess(), 10000, path)); + await Task.WhenAny(importOp.Success, Task.Delay(10000)); + + Assert.Single(_imageList.Images); + ImageAsserts.Similar(ImageResources.dog, _imageList.Images[0].GetClonedImage().Render()); + } + + [Fact] + public async Task ProcessCoordinatorScanWithDevice() + { + await _desktopController.Initialize(); + Assert.True(_processCoordinator.ScanWithDevice(Process.GetCurrentProcess(), 10000, "abc")); + + _ = _desktopScanController.Received().ScanWithDevice("abc"); + } } \ No newline at end of file diff --git a/NAPS2.Lib.WinForms/Platform/Windows/WindowsApplicationLifecycle.cs b/NAPS2.Lib.WinForms/Platform/Windows/WindowsApplicationLifecycle.cs index eb56ad502f..365fa6e5d9 100644 --- a/NAPS2.Lib.WinForms/Platform/Windows/WindowsApplicationLifecycle.cs +++ b/NAPS2.Lib.WinForms/Platform/Windows/WindowsApplicationLifecycle.cs @@ -12,15 +12,18 @@ public class WindowsApplicationLifecycle : ApplicationLifecycle { private readonly StillImage _sti; private readonly WindowsEventLogger _windowsEventLogger; + private readonly ProcessCoordinator _processCoordinator; private readonly Naps2Config _config; private bool _shouldCreateEventSource; private int _returnCode; - public WindowsApplicationLifecycle(StillImage sti, WindowsEventLogger windowsEventLogger, Naps2Config config) + public WindowsApplicationLifecycle(StillImage sti, WindowsEventLogger windowsEventLogger, + ProcessCoordinator processCoordinator, Naps2Config config) { _sti = sti; _windowsEventLogger = windowsEventLogger; + _processCoordinator = processCoordinator; _config = config; } @@ -104,7 +107,8 @@ bool ElevationRequired(Action action) } } - _shouldCreateEventSource = args.Any(x => x.Equals("/CreateEventSource", StringComparison.InvariantCultureIgnoreCase)); + _shouldCreateEventSource = + args.Any(x => x.Equals("/CreateEventSource", StringComparison.InvariantCultureIgnoreCase)); if (_shouldCreateEventSource) { try @@ -165,8 +169,8 @@ public override void ExitIfRedundant() foreach (var process in GetOtherNaps2Processes()) { // Another instance of NAPS2 is running, so send it the "Scan" signal - ActivateProcess(process); - if (Pipes.SendMessage(process, Pipes.MSG_SCAN_WITH_DEVICE + _sti.DeviceID!)) + SetMainWindowToForeground(process); + if (_processCoordinator.ScanWithDevice(process, 100, _sti.DeviceID!)) { // Successful, so this instance can be closed before showing any UI Environment.Exit(0); @@ -177,21 +181,39 @@ public override void ExitIfRedundant() // Only start one instance if configured for SingleInstance if (_config.Get(c => c.SingleInstance)) { - // See if there's another NAPS2 process running - foreach (var process in GetOtherNaps2Processes()) + if (!_processCoordinator.TryTakeInstanceLock()) { - // Another instance of NAPS2 is running, so send it the "Activate" signal - ActivateProcess(process); - if (Pipes.SendMessage(process, Pipes.MSG_ACTIVATE)) + Log.Debug("Failed to get SingleInstance lock"); + var process = _processCoordinator.GetProcessWithInstanceLock(); + if (process != null) { - // Successful, so this instance should be closed - Environment.Exit(0); + // Another instance of NAPS2 is running, so send it the "Activate" signal + Log.Debug($"Activating process {process.Id}"); + + // For new processes, wait until the process is at least 5 seconds old. + // This might be useful in cases where multiple NAPS2 processes are started at once, e.g. clicking + // to open a group of files associated with NAPS2. + int processAge = (DateTime.Now - process.StartTime).Milliseconds; + int timeout = (5000 - processAge).Clamp(100, 5000); + + SetMainWindowToForeground(process); + bool ok = true; + if (Environment.GetCommandLineArgs() is [_, var arg] && File.Exists(arg)) + { + Log.Debug($"Sending OpenFileRequest for {arg}"); + ok = _processCoordinator.OpenFile(process, timeout, arg); + } + if (ok && _processCoordinator.Activate(process, timeout)) + { + // Successful, so this instance should be closed + Environment.Exit(0); + } } } } } - private static void ActivateProcess(Process process) + private static void SetMainWindowToForeground(Process process) { if (process.MainWindowHandle != IntPtr.Zero) { diff --git a/NAPS2.Lib/EtoForms/Desktop/DesktopController.cs b/NAPS2.Lib/EtoForms/Desktop/DesktopController.cs index 48d8c82452..2b5ff736bc 100644 --- a/NAPS2.Lib/EtoForms/Desktop/DesktopController.cs +++ b/NAPS2.Lib/EtoForms/Desktop/DesktopController.cs @@ -1,6 +1,8 @@ using System.Threading; using Eto.Drawing; using Eto.Forms; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; using NAPS2.EtoForms.Notifications; using NAPS2.ImportExport; using NAPS2.ImportExport.Images; @@ -33,6 +35,7 @@ public class DesktopController private readonly DesktopFormProvider _desktopFormProvider; private readonly IScannedImagePrinter _scannedImagePrinter; private readonly ISharedDeviceManager _sharedDeviceManager; + private readonly ProcessCoordinator _processCoordinator; private readonly RecoveryManager _recoveryManager; private readonly ImageTransfer _imageTransfer = new(); @@ -50,7 +53,7 @@ public DesktopController(ScanningContext scanningContext, UiImageList imageList, DialogHelper dialogHelper, DesktopImagesController desktopImagesController, IDesktopScanController desktopScanController, DesktopFormProvider desktopFormProvider, IScannedImagePrinter scannedImagePrinter, - ISharedDeviceManager sharedDeviceManager, RecoveryManager recoveryManager) + ISharedDeviceManager sharedDeviceManager, ProcessCoordinator processCoordinator, RecoveryManager recoveryManager) { _scanningContext = scanningContext; _imageList = imageList; @@ -70,6 +73,7 @@ public DesktopController(ScanningContext scanningContext, UiImageList imageList, _desktopFormProvider = desktopFormProvider; _scannedImagePrinter = scannedImagePrinter; _sharedDeviceManager = sharedDeviceManager; + _processCoordinator = processCoordinator; _recoveryManager = recoveryManager; } @@ -87,9 +91,10 @@ public async Task Initialize() if (_initialized) return; _initialized = true; _sharedDeviceManager.StartSharing(); - StartPipesServer(); + StartProcessCoordinator(); ShowStartupMessages(); ShowRecoveryPrompt(); + ImportFilesFromCommandLine(); InitThumbnailRendering(); await RunStillImageEvents(); SetFirstRunDate(); @@ -168,7 +173,7 @@ private async Task RunStillImageEvents() public void Cleanup() { if (_suspended) return; - Pipes.KillServer(); + _processCoordinator.KillServer(); _sharedDeviceManager.StopSharing(); if (!SkipRecoveryCleanup && !_config.Get(c => c.KeepSession)) { @@ -256,44 +261,10 @@ public bool PrepareForClosing(bool userClosing) return true; } - private void StartPipesServer() + private void StartProcessCoordinator() { - // Receive messages from other processes - Pipes.StartServer(msg => - { - if (msg.StartsWith(Pipes.MSG_SCAN_WITH_DEVICE, StringComparison.InvariantCulture)) - { - Invoker.Current.Invoke(async () => - await _desktopScanController.ScanWithDevice(msg.Substring(Pipes.MSG_SCAN_WITH_DEVICE.Length))); - } - if (msg.Equals(Pipes.MSG_ACTIVATE)) - { - Invoker.Current.Invoke(() => - { - // TODO: xplat - var formOnTop = Application.Instance.Windows.Last(); - if (formOnTop.WindowState == WindowState.Minimized && PlatformCompat.System.CanUseWin32) - { - Win32.ShowWindow(formOnTop.NativeHandle, Win32.ShowWindowCommands.Restore); - } - formOnTop.BringToFront(); - }); - } - if (msg.Equals(Pipes.MSG_CLOSE_WINDOW)) - { - Invoker.Current.Invoke(() => - { - _desktopFormProvider.DesktopForm.Close(); -#if NET6_0_OR_GREATER - if (OperatingSystem.IsMacOS()) - { - // Closing the main window isn't enough to quit the app on Mac - Application.Instance.Quit(); - } -#endif - }); - } - }); + // Receive messages from other NAPS2 processes + _processCoordinator.StartServer(new ProcessCoordinatorServiceImpl(this)); } private void ShowStartupMessages() @@ -345,18 +316,33 @@ private void ShowRecoveryPrompt() } } + private void ImportFilesFromCommandLine() + { + if (Environment.GetCommandLineArgs() is [_, var arg] && File.Exists(arg)) + { + ImportFiles([arg]); + } + } + private void InitThumbnailRendering() { _thumbnailController.Init(_imageList); } - public void ImportFiles(IEnumerable files) + public void ImportFiles(ICollection files, bool background = false) { var op = _operationFactory.Create(); if (op.Start(OrderFiles(files), _desktopImagesController.ReceiveScannedImage(), new ImportParams { ThumbnailSize = _thumbnailController.RenderSize })) { - _operationProgress.ShowProgress(op); + if (background) + { + _operationProgress.ShowBackgroundProgress(op); + } + else + { + _operationProgress.ShowProgress(op); + } } } @@ -537,4 +523,57 @@ public void Resume() { _suspended = false; } + + private class ProcessCoordinatorServiceImpl(DesktopController controller) : ProcessCoordinatorService.ProcessCoordinatorServiceBase + { + public override Task Activate(ActivateRequest request, ServerCallContext context) + { + Invoker.Current.Invoke(() => + { + var formOnTop = Application.Instance.Windows.Last(); + if (PlatformCompat.System.CanUseWin32) + { + if (formOnTop.WindowState == WindowState.Minimized) + { + Win32.ShowWindow(formOnTop.NativeHandle, Win32.ShowWindowCommands.Restore); + } + Win32.SetForegroundWindow(formOnTop.NativeHandle); + } + else + { + formOnTop.BringToFront(); + } + }); + return Task.FromResult(new Empty()); + } + + public override Task CloseWindow(CloseWindowRequest request, ServerCallContext context) + { + Invoker.Current.Invoke(() => + { + controller._desktopFormProvider.DesktopForm.Close(); +#if NET6_0_OR_GREATER + if (OperatingSystem.IsMacOS()) + { + // Closing the main window isn't enough to quit the app on Mac + Application.Instance.Quit(); + } +#endif + }); + return Task.FromResult(new Empty()); + } + + public override Task OpenFile(OpenFileRequest request, ServerCallContext context) + { + controller.ImportFiles(request.Path, true); + return Task.FromResult(new Empty()); + } + + public override Task ScanWithDevice(ScanWithDeviceRequest request, ServerCallContext context) + { + Invoker.Current.Invoke(async () => + await controller._desktopScanController.ScanWithDevice(request.Device)); + return Task.FromResult(new Empty()); + } + } } \ No newline at end of file diff --git a/NAPS2.Lib/EtoForms/EtoOperationProgress.cs b/NAPS2.Lib/EtoForms/EtoOperationProgress.cs index e5563010c5..c83da2354a 100644 --- a/NAPS2.Lib/EtoForms/EtoOperationProgress.cs +++ b/NAPS2.Lib/EtoForms/EtoOperationProgress.cs @@ -49,10 +49,6 @@ public override void ShowModalProgress(IOperation op) { Attach(op); - var bgOps = _config.Get(c => c.BackgroundOperations); - bgOps = bgOps.Remove(op.GetType().Name); - _config.User.Set(c => c.BackgroundOperations, bgOps); - if (!op.IsFinished) { Invoker.Current.Invoke(() => @@ -73,10 +69,6 @@ public override void ShowBackgroundProgress(IOperation op) { Attach(op); - var bgOps = _config.Get(c => c.BackgroundOperations); - bgOps = bgOps.Add(op.GetType().Name); - _config.User.Set(c => c.BackgroundOperations, bgOps); - if (!op.IsFinished) { Invoker.Current.Invoke(() => _notify.OperationProgress(this, op)); diff --git a/NAPS2.Lib/EtoForms/Notifications/NotificationManager.cs b/NAPS2.Lib/EtoForms/Notifications/NotificationManager.cs index 72902d9ac3..cfeed309e6 100644 --- a/NAPS2.Lib/EtoForms/Notifications/NotificationManager.cs +++ b/NAPS2.Lib/EtoForms/Notifications/NotificationManager.cs @@ -2,15 +2,18 @@ namespace NAPS2.EtoForms.Notifications; public class NotificationManager { - public NotificationManager(ColorScheme colorScheme) + public NotificationManager(ColorScheme colorScheme, Naps2Config config) { ColorScheme = colorScheme; + Config = config; } public List Notifications { get; } = []; public ColorScheme ColorScheme { get; } + public Naps2Config Config { get; } + public event EventHandler? Updated; public event EventHandler? TimersStarting; diff --git a/NAPS2.Lib/EtoForms/Notifications/ProgressNotificationView.cs b/NAPS2.Lib/EtoForms/Notifications/ProgressNotificationView.cs index f2a84ca9e5..0d8030f446 100644 --- a/NAPS2.Lib/EtoForms/Notifications/ProgressNotificationView.cs +++ b/NAPS2.Lib/EtoForms/Notifications/ProgressNotificationView.cs @@ -60,7 +60,11 @@ private void UpdateStatus() protected override void NotificationClicked() { - Manager!.Hide(Model); + var bgOps = Manager!.Config.Get(c => c.BackgroundOperations); + bgOps = bgOps.Remove(_op.GetType().Name); + Manager.Config.User.Set(c => c.BackgroundOperations, bgOps); + + Manager.Hide(Model); _operationProgress.ShowModalProgress(_op); } diff --git a/NAPS2.Lib/EtoForms/Ui/ProgressForm.cs b/NAPS2.Lib/EtoForms/Ui/ProgressForm.cs index 13f8090650..ef518b6bf5 100644 --- a/NAPS2.Lib/EtoForms/Ui/ProgressForm.cs +++ b/NAPS2.Lib/EtoForms/Ui/ProgressForm.cs @@ -28,6 +28,7 @@ public ProgressForm(Naps2Config config) : base(config) protected override void BuildLayout() { FormStateController.RestoreFormState = false; + FormStateController.SaveFormState = false; LayoutController.Content = L.Column( _status, @@ -113,6 +114,10 @@ private void TryCancelOp() private void RunInBg_Click(object? sender, EventArgs e) { + var bgOps = Config.Get(c => c.BackgroundOperations); + bgOps = bgOps.Add(Operation.GetType().Name); + Config.User.Set(c => c.BackgroundOperations, bgOps); + _background = true; Close(); } diff --git a/NAPS2.Lib/Modules/CommonModule.cs b/NAPS2.Lib/Modules/CommonModule.cs index d81a6be1c0..a2c33d2a04 100644 --- a/NAPS2.Lib/Modules/CommonModule.cs +++ b/NAPS2.Lib/Modules/CommonModule.cs @@ -8,6 +8,7 @@ using NAPS2.Pdf; using NAPS2.Platform.Windows; using NAPS2.Recovery; +using NAPS2.Remoting; using NAPS2.Remoting.Server; using NAPS2.Remoting.Worker; using NAPS2.Scan; @@ -46,6 +47,7 @@ protected override void Load(ContainerBuilder builder) ctx.Resolve(), ctx.Resolve(), Path.Combine(Paths.AppData, "sharing.xml"))).SingleInstance(); + builder.RegisterInstance(ProcessCoordinator.CreateDefault()); // Logging builder.Register(ctx => diff --git a/NAPS2.Lib/NAPS2.Lib.csproj b/NAPS2.Lib/NAPS2.Lib.csproj index d607b243c4..ba7f416e17 100644 --- a/NAPS2.Lib/NAPS2.Lib.csproj +++ b/NAPS2.Lib/NAPS2.Lib.csproj @@ -34,6 +34,7 @@ + @@ -55,6 +56,8 @@ <_Parameter1>NAPS2.Lib.Gtk + + diff --git a/NAPS2.Lib/Remoting/Pipes.cs b/NAPS2.Lib/Remoting/Pipes.cs deleted file mode 100644 index 159e9cfe15..0000000000 --- a/NAPS2.Lib/Remoting/Pipes.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.IO.Pipes; -using System.Text; -using System.Threading; - -namespace NAPS2.Remoting; - -/// -/// A class for simple inter-process communication between NAPS2 instances via named pipes. -/// -public static class Pipes -{ - public const string MSG_SCAN_WITH_DEVICE = "SCAN_WDEV_"; - public const string MSG_ACTIVATE = "ACTIVATE"; - public const string MSG_KILL_PIPE_SERVER = "KILL_PIPE_SERVER"; - public const string MSG_CLOSE_WINDOW = "CLOSE_WINDOW"; - - // An arbitrary non-secret unique name with a single format argument (for the process ID). - // This could be edtion/version-specific, but I like the idea that if the user is running a portable version and - // happens to have NAPS2 installed too, the scan button will propagate to the portable version. - private const string PIPE_NAME_FORMAT = "NAPS2_PIPE_v1_{0}"; - // The timeout is small since pipe connections should be on the local machine only. - private const int TIMEOUT = 1000; - - private static bool _serverRunning; - - private static string GetPipeName(Process process) - { - return string.Format(PIPE_NAME_FORMAT, process.Id); - } - - /// - /// Send a message to a NAPS2 instance running a pipe server. - /// - /// The process to send the message to. - /// The message to send. - public static bool SendMessage(Process recipient, string msg) - { - try - { - using var pipeClient = new NamedPipeClientStream(".", GetPipeName(recipient), PipeDirection.Out); - //MessageBox.Show("Sending msg:" + msg); - pipeClient.Connect(TIMEOUT); - var streamString = new StreamString(pipeClient); - streamString.WriteString(msg); - //MessageBox.Show("Sent"); - return true; - } - catch (Exception e) - { - Log.ErrorException("Error sending message through pipe", e); - return false; - } - } - - /// - /// Start a pipe server on a background thread, calling the callback each time a message is received. Only one pipe server can be running per process. - /// - /// The message callback. - public static void StartServer(Action msgCallback) - { - if (_serverRunning) - { - return; - } - var thread = new Thread(() => - { - try - { - using var pipeServer = new NamedPipeServerStream(GetPipeName(Process.GetCurrentProcess()), PipeDirection.In); - while (true) - { - pipeServer.WaitForConnection(); - var streamString = new StreamString(pipeServer); - var msg = streamString.ReadString(); - //MessageBox.Show("Received msg:" + msg); - if (msg == MSG_KILL_PIPE_SERVER) - { - break; - } - msgCallback(msg); - pipeServer.Disconnect(); - } - } - catch (Exception ex) - { - Log.ErrorException("Error in named pipe server", ex); - } - _serverRunning = false; - }); - _serverRunning = true; - thread.Start(); - } - - /// - /// Kills the pipe server background thread if one is running. - /// - public static void KillServer() - { - if (_serverRunning) - { - SendMessage(Process.GetCurrentProcess(), MSG_KILL_PIPE_SERVER); - } - } - - /// - /// From https://msdn.microsoft.com/en-us/library/bb546085%28v=vs.110%29.aspx - /// - private class StreamString - { - private Stream _ioStream; - private UnicodeEncoding _streamEncoding; - - public StreamString(Stream ioStream) - { - _ioStream = ioStream; - _streamEncoding = new UnicodeEncoding(); - } - - public string ReadString() - { - int len; - len = _ioStream.ReadByte() * 256; - len += _ioStream.ReadByte(); - byte[] inBuffer = new byte[len]; - _ioStream.Read(inBuffer, 0, len); - - return _streamEncoding.GetString(inBuffer); - } - - public int WriteString(string outString) - { - byte[] outBuffer = _streamEncoding.GetBytes(outString); - int len = outBuffer.Length; - if (len > UInt16.MaxValue) - { - len = (int)UInt16.MaxValue; - } - _ioStream.WriteByte((byte)(len / 256)); - _ioStream.WriteByte((byte)(len & 255)); - _ioStream.Write(outBuffer, 0, len); - _ioStream.Flush(); - - return outBuffer.Length + 2; - } - } -} \ No newline at end of file diff --git a/NAPS2.Lib/Remoting/ProcessCoordinator.cs b/NAPS2.Lib/Remoting/ProcessCoordinator.cs new file mode 100644 index 0000000000..edaea150fb --- /dev/null +++ b/NAPS2.Lib/Remoting/ProcessCoordinator.cs @@ -0,0 +1,105 @@ +using System.Text; +using GrpcDotNetNamedPipes; +using static NAPS2.Remoting.ProcessCoordinatorService; + +namespace NAPS2.Remoting; + +/// +/// Manages communication and coordination between multiple NAPS2 GUI processes. Specifically: +/// - Allows sending messages to other NAPS2 processes via named pipes +/// - Allows taking the SingleInstance lock (or checking which process currently owns it) +/// This is different than the worker service - workers are owned by the parent process and are considered part of the +/// same unit. Instead, this class handles the case where the user (or a system feature like StillImage) opens NAPS2 +/// twice. +/// +public class ProcessCoordinator(string instanceLockPath, string pipeNameFormat) +{ + public static ProcessCoordinator CreateDefault() => + new(Path.Combine(Paths.AppData, "instance.lock"), "NAPS2_PIPE_v2_{0}"); + + private NamedPipeServer? _server; + private FileStream? _instanceLock; + + private string GetPipeName(Process process) + { + return string.Format(pipeNameFormat, process.Id); + } + + public void StartServer(ProcessCoordinatorServiceBase service) + { + _server = new NamedPipeServer(GetPipeName(Process.GetCurrentProcess())); + ProcessCoordinatorService.BindService(_server.ServiceBinder, service); + _server.Start(); + } + + public void KillServer() + { + _server?.Kill(); + } + + private ProcessCoordinatorServiceClient GetClient(Process recipient, int timeout) => + new(new NamedPipeChannel(".", GetPipeName(recipient), + new NamedPipeChannelOptions { ConnectionTimeout = timeout })); + + private bool TrySendMessage(Process recipient, int timeout, Action send) + { + var client = GetClient(recipient, timeout); + try + { + send(client); + return true; + } + catch (Exception) + { + return false; + } + } + + public bool Activate(Process recipient, int timeout) => + TrySendMessage(recipient, timeout, client => client.Activate(new ActivateRequest())); + + public bool CloseWindow(Process recipient, int timeout) => + TrySendMessage(recipient, timeout, client => client.CloseWindow(new CloseWindowRequest())); + + public bool ScanWithDevice(Process recipient, int timeout, string device) => + TrySendMessage(recipient, timeout, + client => client.ScanWithDevice(new ScanWithDeviceRequest { Device = device })); + + public bool OpenFile(Process recipient, int timeout, string path) => + TrySendMessage(recipient, timeout, + client => client.OpenFile(new OpenFileRequest { Path = { path } })); + + public bool TryTakeInstanceLock() + { + if (_instanceLock != null) + { + return true; + } + try + { + _instanceLock = new FileStream(instanceLockPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + _instanceLock.SetLength(0); + using var writer = new StreamWriter(_instanceLock, Encoding.UTF8, 1024, true); + writer.WriteLine(Process.GetCurrentProcess().Id); + } + catch (Exception) + { + return false; + } + return true; + } + + public Process? GetProcessWithInstanceLock() + { + try + { + using var reader = new FileStream(instanceLockPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var id = int.Parse(new StreamReader(reader).ReadLine()?.Trim() ?? ""); + return Process.GetProcessById(id); + } + catch (Exception) + { + return null; + } + } +} \ No newline at end of file diff --git a/NAPS2.Lib/Remoting/ProcessCoordinatorService.proto b/NAPS2.Lib/Remoting/ProcessCoordinatorService.proto new file mode 100644 index 0000000000..8ff6eb815b --- /dev/null +++ b/NAPS2.Lib/Remoting/ProcessCoordinatorService.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package NAPS2.Remoting; + +import "google/protobuf/empty.proto"; + +service ProcessCoordinatorService { + rpc Activate (ActivateRequest) returns (google.protobuf.Empty) {} + rpc CloseWindow (CloseWindowRequest) returns (google.protobuf.Empty) {} + rpc ScanWithDevice (ScanWithDeviceRequest) returns (google.protobuf.Empty) {} + rpc OpenFile (OpenFileRequest) returns (google.protobuf.Empty) {} +} + +message ActivateRequest { +} + +message CloseWindowRequest { +} + +message ScanWithDeviceRequest { + string device = 1; +} + +message OpenFileRequest { + repeated string path = 1; +} \ No newline at end of file