Rework cross-process coordination and support "Open With"

#146
This commit is contained in:
Ben Olden-Cooligan 2024-03-30 13:00:25 -07:00
parent 0c57cd167f
commit 484941c7ad
13 changed files with 296 additions and 214 deletions

View File

@ -1,4 +1,3 @@
using System.Threading;
using NAPS2.App.Tests.Targets;
using NAPS2.Remoting;
using NAPS2.Sdk.Tests;
@ -23,8 +22,8 @@ public class GuiAppTests : ContextualTests
}
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);

View File

@ -2,9 +2,9 @@ using NAPS2.EtoForms;
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 class DesktopControllerTests : ContextualTests
_scannedImagePrinter = Substitute.For<IScannedImagePrinter>();
_thumbnailController = new ThumbnailController(_thumbnailRenderQueue, _config);
_sharedDeviceManager = Substitute.For<ISharedDeviceManager>();
_processCoordinator =
new ProcessCoordinator(Path.Combine(FolderPath, "instance.lock"), Guid.NewGuid().ToString("D"));
ScanningContext.WorkerFactory = Substitute.For<IWorkerFactory>();
_desktopController = new DesktopController(
ScanningContext,
@ -82,6 +85,7 @@ public class DesktopControllerTests : ContextualTests
_desktopFormProvider,
_scannedImagePrinter,
_sharedDeviceManager,
_processCoordinator,
new RecoveryManager(ScanningContext)
);
@ -262,4 +266,28 @@ public class DesktopControllerTests : ContextualTests
_updateChecker.ReceivedCallsCount(1);
_notify.ReceivedCallsCount(1);
}
[Fact]
public async Task ProcessCoordinatorOpenFile()
{
var importOp = new ImportOperation(new FileImporter(ScanningContext));
_operationFactory.Create<ImportOperation>().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");
}
}

View File

@ -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 @@ public class WindowsApplicationLifecycle : ApplicationLifecycle
}
}
_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 class WindowsApplicationLifecycle : ApplicationLifecycle
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 class WindowsApplicationLifecycle : ApplicationLifecycle
// 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)
{

View File

@ -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 class DesktopController
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 class DesktopController
_desktopFormProvider = desktopFormProvider;
_scannedImagePrinter = scannedImagePrinter;
_sharedDeviceManager = sharedDeviceManager;
_processCoordinator = processCoordinator;
_recoveryManager = recoveryManager;
}
@ -87,9 +91,10 @@ public class DesktopController
if (_initialized) return;
_initialized = true;
_sharedDeviceManager.StartSharing();
StartPipesServer();
StartProcessCoordinator();
ShowStartupMessages();
ShowRecoveryPrompt();
ImportFilesFromCommandLine();
InitThumbnailRendering();
await RunStillImageEvents();
SetFirstRunDate();
@ -168,7 +173,7 @@ public class DesktopController
public void Cleanup()
{
if (_suspended) return;
Pipes.KillServer();
_processCoordinator.KillServer();
_sharedDeviceManager.StopSharing();
if (!SkipRecoveryCleanup && !_config.Get(c => c.KeepSession))
{
@ -256,44 +261,10 @@ public class DesktopController
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 @@ public class DesktopController
}
}
private void ImportFilesFromCommandLine()
{
if (Environment.GetCommandLineArgs() is [_, var arg] && File.Exists(arg))
{
ImportFiles([arg]);
}
}
private void InitThumbnailRendering()
{
_thumbnailController.Init(_imageList);
}
public void ImportFiles(IEnumerable<string> files)
public void ImportFiles(ICollection<string> files, bool background = false)
{
var op = _operationFactory.Create<ImportOperation>();
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 class DesktopController
{
_suspended = false;
}
private class ProcessCoordinatorServiceImpl(DesktopController controller) : ProcessCoordinatorService.ProcessCoordinatorServiceBase
{
public override Task<Empty> 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<Empty> 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<Empty> OpenFile(OpenFileRequest request, ServerCallContext context)
{
controller.ImportFiles(request.Path, true);
return Task.FromResult(new Empty());
}
public override Task<Empty> ScanWithDevice(ScanWithDeviceRequest request, ServerCallContext context)
{
Invoker.Current.Invoke(async () =>
await controller._desktopScanController.ScanWithDevice(request.Device));
return Task.FromResult(new Empty());
}
}
}

View File

@ -49,10 +49,6 @@ public class EtoOperationProgress : OperationProgress
{
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 class EtoOperationProgress : OperationProgress
{
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));

View File

@ -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<NotificationModel> Notifications { get; } = [];
public ColorScheme ColorScheme { get; }
public Naps2Config Config { get; }
public event EventHandler? Updated;
public event EventHandler? TimersStarting;

View File

@ -60,7 +60,11 @@ public class ProgressNotificationView : NotificationView
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);
}

View File

@ -28,6 +28,7 @@ public class ProgressForm : EtoDialogBase
protected override void BuildLayout()
{
FormStateController.RestoreFormState = false;
FormStateController.SaveFormState = false;
LayoutController.Content = L.Column(
_status,
@ -113,6 +114,10 @@ public class ProgressForm : EtoDialogBase
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();
}

View File

@ -8,6 +8,7 @@ using NAPS2.Ocr;
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 @@ public class CommonModule : Module
ctx.Resolve<ScanningContext>(),
ctx.Resolve<Naps2Config>(),
Path.Combine(Paths.AppData, "sharing.xml"))).SingleInstance();
builder.RegisterInstance(ProcessCoordinator.CreateDefault());
// Logging
builder.Register<ILogger>(ctx =>

View File

@ -34,6 +34,7 @@
<PackageReference Include="Autofac" Version="8.0.0" />
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Grpc.Tools" Version="2.62.0" PrivateAssets="all" />
<PackageReference Include="MimeKitLite" Version="4.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.2.8" />
@ -55,6 +56,8 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>NAPS2.Lib.Gtk</_Parameter1>
</AssemblyAttribute>
<Protobuf Include="**/*.proto" Access="Public" />
</ItemGroup>
<ItemGroup>

View File

@ -1,146 +0,0 @@
using System.IO.Pipes;
using System.Text;
using System.Threading;
namespace NAPS2.Remoting;
/// <summary>
/// A class for simple inter-process communication between NAPS2 instances via named pipes.
/// </summary>
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);
}
/// <summary>
/// Send a message to a NAPS2 instance running a pipe server.
/// </summary>
/// <param name="recipient">The process to send the message to.</param>
/// <param name="msg">The message to send.</param>
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;
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="msgCallback">The message callback.</param>
public static void StartServer(Action<string> 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();
}
/// <summary>
/// Kills the pipe server background thread if one is running.
/// </summary>
public static void KillServer()
{
if (_serverRunning)
{
SendMessage(Process.GetCurrentProcess(), MSG_KILL_PIPE_SERVER);
}
}
/// <summary>
/// From https://msdn.microsoft.com/en-us/library/bb546085%28v=vs.110%29.aspx
/// </summary>
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;
}
}
}

View File

@ -0,0 +1,105 @@
using System.Text;
using GrpcDotNetNamedPipes;
using static NAPS2.Remoting.ProcessCoordinatorService;
namespace NAPS2.Remoting;
/// <summary>
/// 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.
/// </summary>
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<ProcessCoordinatorServiceClient> 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;
}
}
}

View File

@ -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;
}