mirror of
https://github.com/cyanfish/naps2.git
synced 2024-09-11 15:26:55 +03:00
Sane: Rework non-deterministic ID handling
The new setup ensures each operation happens in a separate worker process, which is important as libusb on macOS seems to have some issues with running multiple SANE commands in a row.
This commit is contained in:
parent
03d993d146
commit
506ec8d70b
@ -44,6 +44,8 @@ internal interface ISystemCompat
|
||||
|
||||
string SaneLibraryName { get; }
|
||||
|
||||
bool IsLibUsbReliable { get; }
|
||||
|
||||
IntPtr LoadLibrary(string path);
|
||||
|
||||
IntPtr LoadSymbol(IntPtr libraryHandle, string symbol);
|
||||
|
@ -53,6 +53,8 @@ internal class LinuxSystemCompat : ISystemCompat
|
||||
|
||||
public string SaneLibraryName => "libsane.so.1";
|
||||
|
||||
public bool IsLibUsbReliable => true;
|
||||
|
||||
public IntPtr LoadLibrary(string path) => LinuxInterop.dlopen(path, RTLD_LAZY | RTLD_GLOBAL);
|
||||
|
||||
public IntPtr LoadSymbol(IntPtr libraryHandle, string symbol) => LinuxInterop.dlsym(libraryHandle, symbol);
|
||||
|
@ -64,6 +64,8 @@ internal class MacSystemCompat : ISystemCompat
|
||||
|
||||
public string SaneLibraryName => "libsane.1.dylib";
|
||||
|
||||
public bool IsLibUsbReliable => false;
|
||||
|
||||
public IntPtr LoadLibrary(string path) => MacInterop.dlopen(path, RTLD_LAZY | RTLD_GLOBAL);
|
||||
|
||||
public IntPtr LoadSymbol(IntPtr libraryHandle, string symbol) => MacInterop.dlsym(libraryHandle, symbol);
|
||||
|
@ -47,6 +47,8 @@ internal abstract class WindowsSystemCompat : ISystemCompat
|
||||
|
||||
public string SaneLibraryName => "sane.dll";
|
||||
|
||||
public bool IsLibUsbReliable => true;
|
||||
|
||||
public IntPtr LoadLibrary(string path) => Win32.LoadLibrary(path);
|
||||
|
||||
public string GetLoadError() => Marshal.GetLastWin32Error().ToString();
|
||||
|
@ -11,7 +11,6 @@ internal class SaneClient : SaneNativeObject
|
||||
{
|
||||
lock (SaneLock)
|
||||
{
|
||||
saneInstallation.Initialize();
|
||||
return new SaneNativeLibrary(saneInstallation.LibraryPath, saneInstallation.LibraryDeps);
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ namespace NAPS2.Scan.Internal.Sane;
|
||||
internal class SaneScanDriver : IScanDriver
|
||||
{
|
||||
private static string? _customConfigDir;
|
||||
|
||||
|
||||
private readonly ScanningContext _scanningContext;
|
||||
|
||||
public SaneScanDriver(ScanningContext scanningContext)
|
||||
@ -22,15 +22,6 @@ internal class SaneScanDriver : IScanDriver
|
||||
: File.Exists("/.flatpak-info")
|
||||
? new FlatpakSaneInstallation()
|
||||
: new SystemSaneInstallation();
|
||||
if (_customConfigDir == null && !OperatingSystem.IsWindows())
|
||||
{
|
||||
// SANE caches the SANE_CONFIG_DIR environment variable process-wide, which means that we can't willy-nilly
|
||||
// change the config dir. However, if we use a static directory name and only create the actual directory
|
||||
// when we want to use it, SANE will (without caching) use the directory when it exists, and fall back to
|
||||
// the default config dir otherwise.
|
||||
_customConfigDir = Path.Combine(_scanningContext.TempFolderPath, Path.GetRandomFileName());
|
||||
Installation.SetCustomConfigDir(_customConfigDir);
|
||||
}
|
||||
#else
|
||||
Installation = null!;
|
||||
#endif
|
||||
@ -53,20 +44,39 @@ internal class SaneScanDriver : IScanDriver
|
||||
|
||||
return Task.Run(() =>
|
||||
{
|
||||
// TODO: This is crashing after a delay for no apparent reason.
|
||||
// That's okay because we're in a worker process, but ideally we could fix it in SANE.
|
||||
using var client = new SaneClient(Installation);
|
||||
// TODO: We can use device.type and .vendor to help pick an icon etc.
|
||||
// https://sane-project.gitlab.io/standard/api.html#device-descriptor-type
|
||||
if (Installation.CanStreamDevices)
|
||||
Installation.Initialize();
|
||||
string? tempConfigDir = MaybeCreateTempConfigDirForSingleBackend(options.SaneOptions.Backend);
|
||||
try
|
||||
{
|
||||
client.StreamDevices(MaybeCallback, cancelToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var device in client.GetDevices())
|
||||
// TODO: This is crashing after a delay for no apparent reason.
|
||||
// That's okay because we're in a worker process, but ideally we could fix it in SANE.
|
||||
using var client = new SaneClient(Installation);
|
||||
// TODO: We can use device.type and .vendor to help pick an icon etc.
|
||||
// https://sane-project.gitlab.io/standard/api.html#device-descriptor-type
|
||||
if (Installation.CanStreamDevices)
|
||||
{
|
||||
MaybeCallback(device);
|
||||
client.StreamDevices(MaybeCallback, cancelToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var device in client.GetDevices())
|
||||
{
|
||||
MaybeCallback(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempConfigDir != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempConfigDir, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_scanningContext.Logger.LogDebug(ex, "Error cleaning up temp SANE config dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -110,147 +120,101 @@ internal class SaneScanDriver : IScanDriver
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetBackend(string saneDeviceName)
|
||||
{
|
||||
return saneDeviceName.Split(':')[0];
|
||||
}
|
||||
public static string GetBackend(ScanDevice device) => GetBackend(device.ID);
|
||||
|
||||
private static string GetBackend(string saneDeviceName) => saneDeviceName.Split(':')[0];
|
||||
|
||||
public Task Scan(ScanOptions options, CancellationToken cancelToken, IScanEvents scanEvents,
|
||||
Action<IMemoryImage> callback)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
bool hasAtLeastOneImage = false;
|
||||
try
|
||||
{
|
||||
ScanWithSaneDevice(options, cancelToken, scanEvents, callback, options.Device!.ID);
|
||||
}
|
||||
catch (DeviceOfflineException)
|
||||
{
|
||||
// Some SANE backends (e.g. airscan, genesys) have inconsistent IDs so "device offline" might actually
|
||||
// just mean "device id has changed". We can query for a "backup" device that matches the name of the
|
||||
// original device, and assume it's the same physical device, which should generally be correct.
|
||||
string? backupDeviceId = QueryForBackupSaneDevice(options.Device!);
|
||||
if (backupDeviceId == null)
|
||||
Installation.Initialize();
|
||||
using var client = new SaneClient(Installation);
|
||||
if (cancelToken.IsCancellationRequested) return;
|
||||
_scanningContext.Logger.LogDebug("Opening SANE Device \"{ID}\"", options.Device!.ID);
|
||||
using var device = client.OpenDevice(options.Device.ID);
|
||||
if (cancelToken.IsCancellationRequested) return;
|
||||
var optionData = SetOptions(device, options);
|
||||
var cancelOnce = new Once(device.Cancel);
|
||||
cancelToken.Register(cancelOnce.Run);
|
||||
try
|
||||
{
|
||||
throw;
|
||||
if (!optionData.IsFeeder)
|
||||
{
|
||||
var image = ScanPage(device, scanEvents, optionData) ??
|
||||
throw new DeviceException("SANE expected image");
|
||||
callback(image);
|
||||
}
|
||||
else
|
||||
{
|
||||
while (ScanPage(device, scanEvents, optionData) is { } image)
|
||||
{
|
||||
hasAtLeastOneImage = true;
|
||||
callback(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancelOnce.Run();
|
||||
}
|
||||
}
|
||||
catch (SaneException ex)
|
||||
{
|
||||
switch (ex.Status)
|
||||
{
|
||||
case SaneStatus.Good:
|
||||
case SaneStatus.Cancelled:
|
||||
return;
|
||||
case SaneStatus.NoDocs:
|
||||
if (!hasAtLeastOneImage)
|
||||
{
|
||||
throw new DeviceFeederEmptyException();
|
||||
}
|
||||
|
||||
break;
|
||||
case SaneStatus.DeviceBusy:
|
||||
throw new DeviceBusyException();
|
||||
case SaneStatus.Invalid:
|
||||
// TODO: Maybe not always correct? e.g. when setting options
|
||||
throw new DeviceOfflineException();
|
||||
case SaneStatus.Jammed:
|
||||
throw new DevicePaperJamException();
|
||||
case SaneStatus.CoverOpen:
|
||||
throw new DeviceCoverOpenException();
|
||||
default:
|
||||
throw new DeviceException($"SANE error: {ex.Status}");
|
||||
}
|
||||
ScanWithSaneDevice(options, cancelToken, scanEvents, callback, backupDeviceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ScanWithSaneDevice(ScanOptions options, CancellationToken cancelToken, IScanEvents scanEvents,
|
||||
Action<IMemoryImage> callback, string deviceId)
|
||||
private string? MaybeCreateTempConfigDirForSingleBackend(string? backendName)
|
||||
{
|
||||
bool hasAtLeastOneImage = false;
|
||||
try
|
||||
if (string.IsNullOrEmpty(backendName))
|
||||
{
|
||||
using var client = new SaneClient(Installation);
|
||||
if (cancelToken.IsCancellationRequested) return;
|
||||
_scanningContext.Logger.LogDebug("Opening SANE Device \"{ID}\"", deviceId);
|
||||
using var device = client.OpenDevice(deviceId);
|
||||
if (cancelToken.IsCancellationRequested) return;
|
||||
var optionData = SetOptions(device, options);
|
||||
var cancelOnce = new Once(device.Cancel);
|
||||
cancelToken.Register(cancelOnce.Run);
|
||||
try
|
||||
{
|
||||
if (!optionData.IsFeeder)
|
||||
{
|
||||
var image = ScanPage(device, scanEvents, optionData) ??
|
||||
throw new DeviceException("SANE expected image");
|
||||
callback(image);
|
||||
}
|
||||
else
|
||||
{
|
||||
while (ScanPage(device, scanEvents, optionData) is { } image)
|
||||
{
|
||||
hasAtLeastOneImage = true;
|
||||
callback(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancelOnce.Run();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (SaneException ex)
|
||||
{
|
||||
switch (ex.Status)
|
||||
{
|
||||
case SaneStatus.Good:
|
||||
case SaneStatus.Cancelled:
|
||||
return;
|
||||
case SaneStatus.NoDocs:
|
||||
if (!hasAtLeastOneImage)
|
||||
{
|
||||
throw new DeviceFeederEmptyException();
|
||||
}
|
||||
|
||||
break;
|
||||
case SaneStatus.DeviceBusy:
|
||||
throw new DeviceBusyException();
|
||||
case SaneStatus.Invalid:
|
||||
// TODO: Maybe not always correct? e.g. when setting options
|
||||
throw new DeviceOfflineException();
|
||||
case SaneStatus.Jammed:
|
||||
throw new DevicePaperJamException();
|
||||
case SaneStatus.CoverOpen:
|
||||
throw new DeviceCoverOpenException();
|
||||
default:
|
||||
throw new DeviceException($"SANE error: {ex.Status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? QueryForBackupSaneDevice(ScanDevice device)
|
||||
{
|
||||
// If we couldn't get an ID match, we can call GetDevices again and see if we can find a name match.
|
||||
// This can be very slow (10+ seconds) if we have to query every single backend (the normal SANE behavior for
|
||||
// GetDevices). We can hack this by creating a temporary SANE config dir that only references the single backend
|
||||
// we need, so it ends up being only ~1s.
|
||||
_scanningContext.Logger.LogDebug(
|
||||
"SANE Device appears offline; re-querying in case of ID change for name \"{Name}\"", device.Name);
|
||||
string? tempConfigDir = MaybeCreateTempConfigDirForSingleBackend(GetBackend(device.ID));
|
||||
try
|
||||
{
|
||||
using var client = new SaneClient(Installation);
|
||||
var backupDevice = client.GetDevices()
|
||||
.FirstOrDefault(deviceInfo => GetName(deviceInfo) == device.Name);
|
||||
if (backupDevice.Name == null)
|
||||
{
|
||||
_scanningContext.Logger.LogDebug("No matching device found");
|
||||
return null;
|
||||
}
|
||||
return backupDevice.Name;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempConfigDir != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempConfigDir, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_scanningContext.Logger.LogDebug(ex, "Error cleaning up temp SANE config dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? MaybeCreateTempConfigDirForSingleBackend(string backendName)
|
||||
{
|
||||
if (!Directory.Exists(Installation.DefaultConfigDir) || _customConfigDir == null)
|
||||
if (!Directory.Exists(Installation.DefaultConfigDir))
|
||||
{
|
||||
// Non-typical SANE installation where we don't know the config dir and can't do this optimization
|
||||
return null;
|
||||
}
|
||||
// SANE normally doesn't provide a way to only query a single backend - it's all or nothing.
|
||||
// However, there is a workaround - if we use the SANE_CONFIG_DIR environment variable, we can specify a custom
|
||||
// config dir, which can have a dll.conf file that only has a single backend specified.
|
||||
if (_customConfigDir == null)
|
||||
{
|
||||
// SANE caches the SANE_CONFIG_DIR environment variable process-wide, which means that we can't willy-nilly
|
||||
// change the config dir. However, if we use a static directory name and only create the actual directory
|
||||
// when we want to use it, SANE will (without caching) use the directory when it exists, and fall back to
|
||||
// the default config dir otherwise.
|
||||
_customConfigDir = Path.Combine(_scanningContext.TempFolderPath, Path.GetRandomFileName());
|
||||
Installation.SetCustomConfigDir(_customConfigDir);
|
||||
}
|
||||
// By using a custom config dir with a dll.conf file that only has a single backend specified, we can force SANE
|
||||
// to only check that backend
|
||||
Directory.CreateDirectory(_customConfigDir);
|
||||
// Copy the backend.conf file in case there's any important backend-specific configuration
|
||||
var backendConfFile = $"{backendName}.conf";
|
||||
@ -264,7 +228,7 @@ internal class SaneScanDriver : IScanDriver
|
||||
File.WriteAllText(Path.Combine(_customConfigDir, "dll.conf"), backendName);
|
||||
// Create an empty dll.d dir so SANE doesn't use the default one
|
||||
Directory.CreateDirectory(Path.Combine(_customConfigDir, "dll.d"));
|
||||
_scanningContext.Logger.LogDebug("Create temp SANE config dir {Dir}", _customConfigDir);
|
||||
_scanningContext.Logger.LogDebug("Created temp SANE config dir {Dir}", _customConfigDir);
|
||||
return _customConfigDir;
|
||||
}
|
||||
|
||||
|
@ -8,8 +8,7 @@ internal class ScanOptionsValidator
|
||||
{
|
||||
public ScanOptions ValidateAll(ScanOptions options, ScanningContext scanningContext, bool requireDevice)
|
||||
{
|
||||
// Easy deep copy. Ideally we'd do this in a more efficient way.
|
||||
options = options.ToXml().FromXml<ScanOptions>();
|
||||
options = options.Clone();
|
||||
|
||||
if (options.Device != null && options.Driver != Driver.Default)
|
||||
{
|
||||
|
@ -5,4 +5,8 @@
|
||||
/// </summary>
|
||||
public class SaneOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Limit the devices queried by GetDevices/GetDeviceList to the given SANE backend.
|
||||
/// </summary>
|
||||
public string? Backend { get; set; }
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NAPS2.Ocr;
|
||||
using NAPS2.Scan.Exceptions;
|
||||
using NAPS2.Scan.Internal;
|
||||
using NAPS2.Scan.Internal.Sane;
|
||||
|
||||
namespace NAPS2.Scan;
|
||||
|
||||
@ -138,17 +141,61 @@ public class ScanController
|
||||
ScanStartCallback();
|
||||
return AsyncProducers.RunProducer<ProcessedImage>(async produceImage =>
|
||||
{
|
||||
try
|
||||
var bridge = _scanBridgeFactory.Create(options);
|
||||
|
||||
async Task DoScan(ScanOptions actualOptions)
|
||||
{
|
||||
var bridge = _scanBridgeFactory.Create(options);
|
||||
await bridge.Scan(options, cancelToken, new ScanEvents(PageStartCallback, PageProgressCallback),
|
||||
await bridge.Scan(actualOptions, cancelToken, new ScanEvents(PageStartCallback, PageProgressCallback),
|
||||
(image, postProcessingContext) =>
|
||||
{
|
||||
image = _localPostProcessor.PostProcess(image, options, postProcessingContext);
|
||||
image = _localPostProcessor.PostProcess(image, actualOptions, postProcessingContext);
|
||||
produceImage(image);
|
||||
PageEndCallback(image);
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await DoScan(options);
|
||||
}
|
||||
catch (DeviceOfflineException) when
|
||||
(options.Driver == Driver.Sane &&
|
||||
(_scanningContext.WorkerFactory != null || PlatformCompat.System.IsLibUsbReliable))
|
||||
{
|
||||
// Some SANE backends (e.g. airscan, genesys) have inconsistent IDs so "device offline" might actually
|
||||
// just mean "device id has changed". We can query for a device that matches the name of the
|
||||
// original device, and assume it's the same physical device, which should generally be correct.
|
||||
//
|
||||
// TODO: Ideally this would be contained within SaneScanDriver, but due to libusb's unreliability on
|
||||
// macOS, we have to make sure each call is in a separate worker process. Makes me wonder if the
|
||||
// scanning pipeline could be redesigned so that drivers have more control over worker processes.
|
||||
_scanningContext.Logger.LogDebug(
|
||||
"SANE Device appears offline; re-querying in case of ID change for name \"{Name}\"",
|
||||
options.Device!.Name);
|
||||
var getDevicesOptions = options.Clone();
|
||||
getDevicesOptions.SaneOptions.Backend = SaneScanDriver.GetBackend(options.Device!);
|
||||
ScanDevice? matchingDevice = null;
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken);
|
||||
await bridge.GetDevices(getDevicesOptions, cts.Token, device =>
|
||||
{
|
||||
if (device.Name == options.Device!.Name)
|
||||
{
|
||||
matchingDevice = device;
|
||||
cts.Cancel();
|
||||
}
|
||||
});
|
||||
if (matchingDevice == null)
|
||||
{
|
||||
_scanningContext.Logger.LogDebug("No matching device found");
|
||||
throw;
|
||||
}
|
||||
var actualOptions = options.Clone();
|
||||
actualOptions.Device = matchingDevice;
|
||||
await DoScan(actualOptions);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scanError = ex;
|
||||
|
@ -1,4 +1,5 @@
|
||||
using NAPS2.Ocr;
|
||||
using NAPS2.Serialization;
|
||||
|
||||
namespace NAPS2.Scan;
|
||||
|
||||
@ -143,4 +144,10 @@ public class ScanOptions
|
||||
public bool FlipDuplexedPages { get; set; }
|
||||
|
||||
public KeyValueScanOptions? KeyValueOptions { get; set; }
|
||||
|
||||
public ScanOptions Clone()
|
||||
{
|
||||
// Easy deep copy. Ideally we'd do this in a more efficient way.
|
||||
return this.ToXml().FromXml<ScanOptions>();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user