diff --git a/Explorer.slnx b/Explorer.slnx
index 3584075..3f7859b 100644
--- a/Explorer.slnx
+++ b/Explorer.slnx
@@ -6,6 +6,8 @@
+
+
@@ -21,6 +23,7 @@
+
diff --git a/src/Explorer.App/AppServices.cs b/src/Explorer.App/AppServices.cs
index 9e0b229..28a211e 100644
--- a/src/Explorer.App/AppServices.cs
+++ b/src/Explorer.App/AppServices.cs
@@ -1,21 +1,11 @@
-using Explorer.Analysis;
using Explorer.Application;
-using Explorer.Domain;
using Explorer.Domain.Abstractions;
-using Explorer.FileOperations;
-using Explorer.Indexing;
-using Explorer.Plugin.Abstractions;
-using Explorer.Plugin.GoogleDrive;
-using Explorer.Plugin.Nextcloud;
-using Explorer.Plugin.OneDrive;
+using Explorer.Hosting;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
-using Explorer.Search;
-using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
namespace Explorer.App;
@@ -23,105 +13,20 @@ public static class AppServices
{
public static IServiceCollection AddExplorer(this IServiceCollection services)
{
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
+ services.AddExplorerCore();
+ services.AddExplorerUi();
+ return services;
+ }
+
+ public static IServiceCollection AddExplorerUi(this IServiceCollection services)
+ {
services.AddSingleton();
- services.AddSingleton(sp =>
- {
- var env = sp.GetRequiredService();
- var logger = sp.GetRequiredService>();
- return new SqliteIndexStore(env.DatabasePath, logger);
- });
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton(sp => sp.GetRequiredService());
- services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddHostedService(sp => sp.GetRequiredService());
- services.AddHostedService(sp => sp.GetRequiredService());
- services.AddHostedService(sp => sp.GetRequiredService());
- services.AddHostedService(sp => sp.GetRequiredService());
services.AddHostedService(sp => sp.GetRequiredService());
- services.AddHostedService();
return services;
}
}
-
-public sealed class WatcherHostedService : BackgroundService
-{
- private readonly DirectoryWatcherHub _hub;
- private readonly SourceManager _sources;
- private readonly TransferQueue _transfers;
- private readonly FolderSyncService _sync;
- private readonly OperationProfileService _profiles;
-
- public WatcherHostedService(
- DirectoryWatcherHub hub,
- SourceManager sources,
- TransferQueue transfers,
- FolderSyncService sync,
- OperationProfileService profiles)
- {
- _hub = hub;
- _sources = sources;
- _transfers = transfers;
- _sync = sync;
- _profiles = profiles;
- }
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
- await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
- while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
- {
- await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
- _transfers.NotifyAvailability();
- await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
- await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
- await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
- }
- }
-}
diff --git a/src/Explorer.App/Explorer.App.csproj b/src/Explorer.App/Explorer.App.csproj
index bc1b548..b8c8afa 100644
--- a/src/Explorer.App/Explorer.App.csproj
+++ b/src/Explorer.App/Explorer.App.csproj
@@ -29,6 +29,10 @@
+
+ false
+
+
@@ -39,4 +43,17 @@
+
+
+ <_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))
+
+
+ <_HostFiles Include="$(_HostDir)Explorer.Host.exe" />
+ <_HostFiles Include="$(_HostDir)Explorer.Host.dll" />
+ <_HostFiles Include="$(_HostDir)Explorer.Host.deps.json" />
+ <_HostFiles Include="$(_HostDir)Explorer.Host.runtimeconfig.json" />
+ <_HostFiles Include="$(_HostDir)Explorer.Host.pdb" />
+
+
+
diff --git a/src/Explorer.App/SettingsWindow.xaml b/src/Explorer.App/SettingsWindow.xaml
index 99b063d..140282f 100644
--- a/src/Explorer.App/SettingsWindow.xaml
+++ b/src/Explorer.App/SettingsWindow.xaml
@@ -61,6 +61,16 @@
Content="Include archive contents in the index"/>
+
+
+
+
+
+
+/// Detects whether this process already has administrator rights.
+/// Elevation is never requested automatically. A future helper may scan USN
+/// under elevation; it must not host the transfer queue or index writer.
+///
public interface IElevatedScanService
{
bool IsElevated { get; }
diff --git a/src/Explorer.Application/RemovableAutoIndexPlanner.cs b/src/Explorer.Application/RemovableAutoIndexPlanner.cs
new file mode 100644
index 0000000..756c14e
--- /dev/null
+++ b/src/Explorer.Application/RemovableAutoIndexPlanner.cs
@@ -0,0 +1,23 @@
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public static class RemovableAutoIndexPlanner
+{
+ public static IReadOnlyList SourceIdsToScan(IEnumerable sources, bool autoIndexRemovable)
+ {
+ if (!autoIndexRemovable)
+ {
+ return [];
+ }
+
+ return sources
+ .Where(source =>
+ source.Kind == SourceKind.Removable
+ && source.Status == SourceStatus.Online
+ && !source.IsIndexed
+ && !string.IsNullOrWhiteSpace(source.LastRootPath))
+ .Select(source => source.Id)
+ .ToList();
+ }
+}
diff --git a/src/Explorer.Application/UiPreferencesStore.cs b/src/Explorer.Application/UiPreferencesStore.cs
index eb11428..8ee2b9d 100644
--- a/src/Explorer.Application/UiPreferencesStore.cs
+++ b/src/Explorer.Application/UiPreferencesStore.cs
@@ -24,7 +24,9 @@ public sealed record UiPreferences(
string? OrganizeDocuments = null,
string? OrganizeInstallers = null,
string? OrganizeArchives = null,
- string? OrganizeDevelopment = null)
+ string? OrganizeDevelopment = null,
+ bool AutoIndexRemovable = false,
+ bool BackgroundHostAtLogon = false)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -69,6 +71,8 @@ public sealed class UiPreferencesStore
"show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
+ "auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
+ "background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
.. SevenZipLines(preferences),
.. GitLines(preferences),
.. OrganizeLines(preferences),
@@ -90,6 +94,8 @@ public sealed class UiPreferencesStore
var showHidden = true;
var showProtected = false;
var autoClearQueue = false;
+ var autoIndexRemovable = false;
+ var backgroundHostAtLogon = false;
string? sevenZipPath = null;
string? gitPath = null;
string? organizePictures = null;
@@ -149,6 +155,14 @@ public sealed class UiPreferencesStore
{
autoClearQueue = IsTrue(value);
}
+ else if (key.Equals("auto-index-removable", StringComparison.OrdinalIgnoreCase))
+ {
+ autoIndexRemovable = IsTrue(value);
+ }
+ else if (key.Equals("background-host-at-logon", StringComparison.OrdinalIgnoreCase))
+ {
+ backgroundHostAtLogon = IsTrue(value);
+ }
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
@@ -215,7 +229,7 @@ public sealed class UiPreferencesStore
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
- organizeDevelopment);
+ organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon);
}
private static IEnumerable SevenZipLines(UiPreferences preferences)
diff --git a/src/Explorer.Application/WorkbenchHost.cs b/src/Explorer.Application/WorkbenchHost.cs
new file mode 100644
index 0000000..11156eb
--- /dev/null
+++ b/src/Explorer.Application/WorkbenchHost.cs
@@ -0,0 +1,15 @@
+using Explorer.Contracts;
+
+namespace Explorer.Application;
+
+public sealed class WorkbenchHost : IWorkbenchHost
+{
+ public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers)
+ {
+ Indexing = indexing;
+ Transfers = transfers;
+ }
+
+ public IIndexingHost Indexing { get; }
+ public ITransferHost Transfers { get; }
+}
diff --git a/src/Explorer.Contracts/IWorkbenchHost.cs b/src/Explorer.Contracts/IWorkbenchHost.cs
new file mode 100644
index 0000000..d38cca1
--- /dev/null
+++ b/src/Explorer.Contracts/IWorkbenchHost.cs
@@ -0,0 +1,36 @@
+using Explorer.Domain;
+
+namespace Explorer.Contracts;
+
+public interface IWorkbenchHost
+{
+ IIndexingHost Indexing { get; }
+ ITransferHost Transfers { get; }
+}
+
+public interface IIndexingHost
+{
+ event EventHandler? ProgressChanged;
+ void EnqueueFullScan(long sourceId);
+ void EnqueueFolderScan(long sourceId, string pathRel);
+ void EnqueueReconcile(long sourceId, string pathRel);
+ void Cancel(long sourceId);
+}
+
+public interface ITransferHost
+{
+ event EventHandler? Changed;
+ event EventHandler? JobFinished;
+ bool IsPaused { get; }
+ IReadOnlyList Snapshot();
+ void PauseAll();
+ void ResumeAll();
+ void Pause(long jobId);
+ void Resume(long jobId);
+ void Retry(long jobId);
+ void Cancel(long jobId);
+ void Dismiss(long jobId);
+ void ClearFinished();
+ bool MoveUp(long jobId);
+ bool MoveDown(long jobId);
+}
diff --git a/src/Explorer.FileOperations/Explorer.FileOperations.csproj b/src/Explorer.FileOperations/Explorer.FileOperations.csproj
index a1f6d9a..089c533 100644
--- a/src/Explorer.FileOperations/Explorer.FileOperations.csproj
+++ b/src/Explorer.FileOperations/Explorer.FileOperations.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/Explorer.FileOperations/TransferQueue.cs b/src/Explorer.FileOperations/TransferQueue.cs
index 9c2a111..a2c6e71 100644
--- a/src/Explorer.FileOperations/TransferQueue.cs
+++ b/src/Explorer.FileOperations/TransferQueue.cs
@@ -1,3 +1,4 @@
+using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
@@ -5,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.FileOperations;
-public sealed class TransferQueue : BackgroundService
+public sealed class TransferQueue : BackgroundService, ITransferHost
{
private readonly IOperationExecutor _executor;
private readonly IIndexStore _store;
diff --git a/src/Explorer.Host/Explorer.Host.csproj b/src/Explorer.Host/Explorer.Host.csproj
new file mode 100644
index 0000000..a0013e6
--- /dev/null
+++ b/src/Explorer.Host/Explorer.Host.csproj
@@ -0,0 +1,21 @@
+
+
+ WinExe
+ net10.0-windows
+ enable
+ enable
+ Explorer.Host
+ Explorer.Host
+ ..\Explorer.App\app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Explorer.Host/Program.cs b/src/Explorer.Host/Program.cs
new file mode 100644
index 0000000..d429a1f
--- /dev/null
+++ b/src/Explorer.Host/Program.cs
@@ -0,0 +1,37 @@
+using Explorer.Hosting;
+using Explorer.Windows;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+
+var env = new WindowsAppEnvironment();
+Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .WriteTo.File(
+ Path.Combine(env.LogDirectory, "explorer-host-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 14)
+ .CreateLogger();
+
+try
+{
+ var builder = Host.CreateApplicationBuilder(args);
+ builder.Services.AddExplorerHostProcess();
+ using var host = builder.Build();
+ await host.RunAsync().ConfigureAwait(false);
+ return 0;
+}
+catch (InvalidOperationException ex) when (ex.Message.Contains("already in use", StringComparison.OrdinalIgnoreCase))
+{
+ Log.Warning(ex, "Index store is already owned; background host exiting");
+ return 1;
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, "Explorer.Host failed");
+ return 1;
+}
+finally
+{
+ await Log.CloseAndFlushAsync().ConfigureAwait(false);
+}
diff --git a/src/Explorer.Hosting/Explorer.Hosting.csproj b/src/Explorer.Hosting/Explorer.Hosting.csproj
new file mode 100644
index 0000000..69ecb8e
--- /dev/null
+++ b/src/Explorer.Hosting/Explorer.Hosting.csproj
@@ -0,0 +1,29 @@
+
+
+ net10.0-windows
+ Explorer.Hosting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Explorer.Hosting/ExplorerHostServices.cs b/src/Explorer.Hosting/ExplorerHostServices.cs
new file mode 100644
index 0000000..f13df25
--- /dev/null
+++ b/src/Explorer.Hosting/ExplorerHostServices.cs
@@ -0,0 +1,100 @@
+using Explorer.Analysis;
+using Explorer.Application;
+using Explorer.Contracts;
+using Explorer.Domain;
+using Explorer.Domain.Abstractions;
+using Explorer.FileOperations;
+using Explorer.Hosting.Ipc;
+using Explorer.Indexing;
+using Explorer.Plugin.Abstractions;
+using Explorer.Plugin.GoogleDrive;
+using Explorer.Plugin.Nextcloud;
+using Explorer.Plugin.OneDrive;
+using Explorer.Search;
+using Explorer.Storage.Sqlite;
+using Explorer.Windows;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Explorer.Hosting;
+
+public static class ExplorerHostServices
+{
+ public static IServiceCollection AddExplorerCore(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ {
+ var env = sp.GetRequiredService();
+ var logger = sp.GetRequiredService>();
+ return new SqliteIndexStore(env.DatabasePath, logger);
+ });
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddHostedService(sp => sp.GetRequiredService());
+ services.AddHostedService();
+ return services;
+ }
+
+ ///
+ /// Registers the background host process: open the store first, then Core workers, then the named pipe.
+ /// Do not call this from the GUI while the GUI still opens the index for write.
+ ///
+ public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.AddHostedService();
+ services.AddExplorerCore();
+ services.AddHostedService();
+ return services;
+ }
+}
diff --git a/src/Explorer.Hosting/HostLogonAutostart.cs b/src/Explorer.Hosting/HostLogonAutostart.cs
new file mode 100644
index 0000000..943a836
--- /dev/null
+++ b/src/Explorer.Hosting/HostLogonAutostart.cs
@@ -0,0 +1,109 @@
+using System.Diagnostics;
+
+namespace Explorer.Hosting;
+
+public static class HostLogonAutostart
+{
+ public const string TaskName = "ExplorerWorkbenchHost";
+
+ public static string? FindHostExecutable()
+ {
+ var candidate = Path.Combine(AppContext.BaseDirectory, "Explorer.Host.exe");
+ return File.Exists(candidate) ? candidate : null;
+ }
+
+ public static string[] CreateTaskArgs(string hostExePath)
+ =>
+ [
+ "/Create",
+ "/TN",
+ TaskName,
+ "/TR",
+ Quote(hostExePath),
+ "/SC",
+ "ONLOGON",
+ "/F",
+ "/RL",
+ "LIMITED"
+ ];
+
+ public static string[] DeleteTaskArgs() => ["/Delete", "/TN", TaskName, "/F"];
+
+ public static bool TryRegister(string hostExePath, out string error)
+ {
+ if (!File.Exists(hostExePath))
+ {
+ error = "Explorer.Host.exe was not found next to Explorer Workbench.";
+ return false;
+ }
+
+ return Run(CreateTaskArgs(hostExePath), out error);
+ }
+
+ public static bool TryUnregister(out string error) => Run(DeleteTaskArgs(), out error);
+
+ private static string Quote(string path) => "\"" + path + "\"";
+
+ private static bool Run(string[] args, out string error)
+ {
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "schtasks.exe",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ }
+ };
+ foreach (var arg in args)
+ {
+ process.StartInfo.ArgumentList.Add(arg);
+ }
+
+ process.Start();
+ if (!process.WaitForExit(8000))
+ {
+ try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
+ error = "Timed out updating the sign-in task.";
+ return false;
+ }
+
+ var stderr = process.StandardError.ReadToEnd();
+ var stdout = process.StandardOutput.ReadToEnd();
+ if (process.ExitCode != 0)
+ {
+ error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
+ if (IsAlreadyAbsent(args, error))
+ {
+ error = "";
+ return true;
+ }
+
+ if (string.IsNullOrWhiteSpace(error))
+ {
+ error = "schtasks exited with code " + process.ExitCode;
+ }
+
+ return false;
+ }
+
+ error = "";
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ return false;
+ }
+ }
+
+ private static bool IsAlreadyAbsent(string[] args, string error)
+ => args.Length > 0
+ && args[0].Equals("/Delete", StringComparison.OrdinalIgnoreCase)
+ && (error.Contains("cannot find", StringComparison.OrdinalIgnoreCase)
+ || error.Contains("not found", StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/Explorer.Hosting/IndexStoreLifetime.cs b/src/Explorer.Hosting/IndexStoreLifetime.cs
new file mode 100644
index 0000000..dabfc75
--- /dev/null
+++ b/src/Explorer.Hosting/IndexStoreLifetime.cs
@@ -0,0 +1,15 @@
+using Explorer.Domain.Abstractions;
+using Microsoft.Extensions.Hosting;
+
+namespace Explorer.Hosting;
+
+public sealed class IndexStoreLifetime : IHostedService
+{
+ private readonly IIndexStore _store;
+
+ public IndexStoreLifetime(IIndexStore store) => _store = store;
+
+ public Task StartAsync(CancellationToken cancellationToken) => _store.OpenAsync(cancellationToken);
+
+ public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync();
+}
diff --git a/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs b/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs
new file mode 100644
index 0000000..5e7a58c
--- /dev/null
+++ b/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Explorer.Domain;
+
+namespace Explorer.Hosting.Ipc;
+
+public sealed class WorkbenchIpcOptions
+{
+ public string PipeName { get; set; } = WorkbenchIpc.DefaultPipeName;
+}
+
+public static class WorkbenchIpc
+{
+ public const int ProtocolVersion = 1;
+
+ public static string DefaultPipeName { get; } = Sanitize("ExplorerWorkbench-" + Environment.UserName);
+
+ public static JsonSerializerOptions Json { get; } = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
+ };
+
+ public static string Sanitize(string name)
+ {
+ var chars = name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
+ return new string(chars);
+ }
+}
+
+internal sealed class IpcEnvelope
+{
+ public int V { get; set; } = WorkbenchIpc.ProtocolVersion;
+ public string? Id { get; set; }
+ public string? Op { get; set; }
+ public string? Evt { get; set; }
+ public bool? Ok { get; set; }
+ public string? Error { get; set; }
+ public long? N { get; set; }
+ public string? S { get; set; }
+ public bool? Flag { get; set; }
+ public bool? Paused { get; set; }
+ public ScanProgress? Progress { get; set; }
+ public TransferJob? Job { get; set; }
+ public TransferJob[]? Jobs { get; set; }
+}
diff --git a/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs b/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs
new file mode 100644
index 0000000..270f1e1
--- /dev/null
+++ b/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs
@@ -0,0 +1,276 @@
+using System.Collections.Concurrent;
+using System.IO.Pipes;
+using System.Text;
+using System.Text.Json;
+using Explorer.Contracts;
+using Explorer.Domain;
+
+namespace Explorer.Hosting.Ipc;
+
+public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
+{
+ private readonly NamedPipeClientStream _pipe;
+ private readonly StreamWriter _writer;
+ private readonly StreamReader _reader;
+ private readonly SemaphoreSlim _send = new(1, 1);
+ private readonly ConcurrentDictionary> _pending = new();
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Task _readLoop;
+ private readonly IndexingProxy _indexing;
+ private readonly TransferProxy _transfers;
+
+ private WorkbenchPipeClient(NamedPipeClientStream pipe)
+ {
+ _pipe = pipe;
+ _writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
+ _reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
+ _indexing = new IndexingProxy(this);
+ _transfers = new TransferProxy(this);
+ _readLoop = ReadLoopAsync(_cts.Token);
+ }
+
+ public IIndexingHost Indexing => _indexing;
+ public ITransferHost Transfers => _transfers;
+
+ public static async Task ConnectAsync(
+ WorkbenchIpcOptions options,
+ TimeSpan timeout,
+ CancellationToken cancellationToken = default)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ Exception? last = null;
+ while (DateTime.UtcNow < deadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var pipe = new NamedPipeClientStream(
+ ".",
+ options.PipeName,
+ PipeDirection.InOut,
+ PipeOptions.Asynchronous);
+ try
+ {
+ var remaining = deadline - DateTime.UtcNow;
+ if (remaining < TimeSpan.FromMilliseconds(50))
+ {
+ remaining = TimeSpan.FromMilliseconds(50);
+ }
+
+ await pipe.ConnectAsync(remaining, cancellationToken).ConfigureAwait(false);
+ var client = new WorkbenchPipeClient(pipe);
+ await client.CallAsync("Ping", cancellationToken).ConfigureAwait(false);
+ return client;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ last = ex;
+ await pipe.DisposeAsync().ConfigureAwait(false);
+ try
+ {
+ await Task.Delay(80, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ }
+ }
+
+ throw new TimeoutException(
+ $"Could not connect to Explorer Workbench host pipe '{options.PipeName}'.", last);
+ }
+
+ internal IpcEnvelope Call(string op, long? n = null, string? s = null)
+ => CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
+
+ internal async Task CallAsync(
+ string op,
+ CancellationToken cancellationToken,
+ long? n = null,
+ string? s = null)
+ {
+ var id = Guid.NewGuid().ToString("N");
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pending[id] = tcs;
+ var request = new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Id = id, Op = op, N = n, S = s };
+ var json = JsonSerializer.Serialize(request, WorkbenchIpc.Json);
+ await _send.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ _pending.TryRemove(id, out _);
+ throw;
+ }
+ finally
+ {
+ _send.Release();
+ }
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token, _cts.Token);
+ using var cancelReg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
+ try
+ {
+ var reply = await tcs.Task.ConfigureAwait(false);
+ if (reply.Ok == false)
+ {
+ throw new InvalidOperationException(reply.Error ?? "Host call failed.");
+ }
+
+ return reply;
+ }
+ finally
+ {
+ _pending.TryRemove(id, out _);
+ }
+ }
+
+ internal void RaiseProgress(ScanProgress progress) => _indexing.Raise(progress);
+ internal void RaiseChanged() => _transfers.RaiseChanged();
+ internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
+
+ private async Task ReadLoopAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ if (line is null)
+ {
+ break;
+ }
+
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ IpcEnvelope? envelope;
+ try
+ {
+ envelope = JsonSerializer.Deserialize(line, WorkbenchIpc.Json);
+ }
+ catch (JsonException)
+ {
+ continue;
+ }
+
+ if (envelope is null)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(envelope.Evt))
+ {
+ switch (envelope.Evt)
+ {
+ case "Indexing.Progress" when envelope.Progress is not null:
+ RaiseProgress(envelope.Progress);
+ break;
+ case "Transfers.Changed":
+ RaiseChanged();
+ break;
+ case "Transfers.JobFinished" when envelope.Job is not null:
+ RaiseFinished(envelope.Job);
+ break;
+ }
+
+ continue;
+ }
+
+ if (envelope.Id is not null && _pending.TryRemove(envelope.Id, out var tcs))
+ {
+ tcs.TrySetResult(envelope);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // shutting down
+ }
+ finally
+ {
+ foreach (var tcs in _pending.Values)
+ {
+ tcs.TrySetCanceled(cancellationToken);
+ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ try
+ {
+ await _writer.DisposeAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ // pipe already closed
+ }
+
+ _reader.Dispose();
+ await _pipe.DisposeAsync().ConfigureAwait(false);
+ try
+ {
+ await _readLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ // reader may still be unwinding after the pipe close
+ }
+ catch (OperationCanceledException)
+ {
+ // expected
+ }
+
+ _cts.Dispose();
+ _send.Dispose();
+ }
+
+ private sealed class IndexingProxy : IIndexingHost
+ {
+ private readonly WorkbenchPipeClient _client;
+ public event EventHandler? ProgressChanged = delegate { };
+
+ public IndexingProxy(WorkbenchPipeClient client) => _client = client;
+
+ public void EnqueueFullScan(long sourceId) => _client.Call("Indexing.EnqueueFullScan", sourceId);
+ public void EnqueueFolderScan(long sourceId, string pathRel)
+ => _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
+ public void EnqueueReconcile(long sourceId, string pathRel)
+ => _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel);
+ public void Cancel(long sourceId) => _client.Call("Indexing.Cancel", sourceId);
+ public void Raise(ScanProgress progress) => ProgressChanged?.Invoke(this, progress);
+ }
+
+ private sealed class TransferProxy : ITransferHost
+ {
+ private readonly WorkbenchPipeClient _client;
+ public event EventHandler? Changed = delegate { };
+ public event EventHandler? JobFinished = delegate { };
+
+ public TransferProxy(WorkbenchPipeClient client) => _client = client;
+
+ public bool IsPaused => _client.Call("Transfers.IsPaused").Paused == true;
+
+ public IReadOnlyList Snapshot()
+ => _client.Call("Transfers.Snapshot").Jobs ?? [];
+
+ public void PauseAll() => _client.Call("Transfers.PauseAll");
+ public void ResumeAll() => _client.Call("Transfers.ResumeAll");
+ public void Pause(long jobId) => _client.Call("Transfers.Pause", jobId);
+ public void Resume(long jobId) => _client.Call("Transfers.Resume", jobId);
+ public void Retry(long jobId) => _client.Call("Transfers.Retry", jobId);
+ public void Cancel(long jobId) => _client.Call("Transfers.Cancel", jobId);
+ public void Dismiss(long jobId) => _client.Call("Transfers.Dismiss", jobId);
+ public void ClearFinished() => _client.Call("Transfers.ClearFinished");
+ public bool MoveUp(long jobId) => _client.Call("Transfers.MoveUp", jobId).Flag == true;
+ public bool MoveDown(long jobId) => _client.Call("Transfers.MoveDown", jobId).Flag == true;
+ public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
+ public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
+ }
+}
diff --git a/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs b/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
new file mode 100644
index 0000000..65378f8
--- /dev/null
+++ b/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
@@ -0,0 +1,233 @@
+using System.IO.Pipes;
+using System.Text;
+using System.Text.Json;
+using Explorer.Contracts;
+using Explorer.Domain;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Explorer.Hosting.Ipc;
+
+public sealed class WorkbenchPipeServer : BackgroundService
+{
+ private readonly IWorkbenchHost _workbench;
+ private readonly WorkbenchIpcOptions _options;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _write = new(1, 1);
+ private readonly TaskCompletionSource _listening = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public WorkbenchPipeServer(
+ IWorkbenchHost workbench,
+ WorkbenchIpcOptions options,
+ ILogger logger)
+ {
+ _workbench = workbench;
+ _options = options;
+ _logger = logger;
+ }
+
+ public Task Listening => _listening.Task;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Listening on named pipe {Pipe} protocol v{Version}", _options.PipeName, WorkbenchIpc.ProtocolVersion);
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var server = new NamedPipeServerStream(
+ _options.PipeName,
+ PipeDirection.InOut,
+ 1,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous);
+ await using (server.ConfigureAwait(false))
+ {
+ _listening.TrySetResult();
+ using var cancelPipe = stoppingToken.Register(() =>
+ {
+ try { server.Dispose(); }
+ catch (ObjectDisposedException) { }
+ catch (IOException) { }
+ });
+ await server.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
+ await ServeAsync(server, stoppingToken).ConfigureAwait(false);
+ }
+ }
+ catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (IOException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Workbench pipe session ended");
+ try
+ {
+ await Task.Delay(250, stoppingToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ private async Task ServeAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
+ {
+ using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
+ await using var writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
+
+ void OnProgress(object? sender, ScanProgress progress)
+ => _ = WriteAsync(writer, new IpcEnvelope { Evt = "Indexing.Progress", Progress = progress }, stoppingToken);
+
+ void OnChanged(object? sender, EventArgs e)
+ => _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.Changed" }, stoppingToken);
+
+ void OnFinished(object? sender, TransferJob job)
+ => _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
+
+ _workbench.Indexing.ProgressChanged += OnProgress;
+ _workbench.Transfers.Changed += OnChanged;
+ _workbench.Transfers.JobFinished += OnFinished;
+ try
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(stoppingToken).ConfigureAwait(false);
+ if (line is null)
+ {
+ break;
+ }
+
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ IpcEnvelope request;
+ try
+ {
+ request = JsonSerializer.Deserialize(line, WorkbenchIpc.Json)
+ ?? new IpcEnvelope { Ok = false, Error = "empty" };
+ }
+ catch (JsonException ex)
+ {
+ await WriteAsync(writer, new IpcEnvelope { Ok = false, Error = ex.Message }, stoppingToken)
+ .ConfigureAwait(false);
+ continue;
+ }
+
+ var response = Handle(request);
+ await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ _workbench.Indexing.ProgressChanged -= OnProgress;
+ _workbench.Transfers.Changed -= OnChanged;
+ _workbench.Transfers.JobFinished -= OnFinished;
+ }
+ }
+
+ internal IpcEnvelope Handle(IpcEnvelope request)
+ {
+ var reply = new IpcEnvelope { Id = request.Id, Ok = true };
+ if (request.V != WorkbenchIpc.ProtocolVersion)
+ {
+ reply.Ok = false;
+ reply.Error = $"Unsupported protocol {request.V}; expected {WorkbenchIpc.ProtocolVersion}.";
+ return reply;
+ }
+
+ try
+ {
+ switch (request.Op)
+ {
+ case "Ping":
+ return reply;
+ case "Indexing.EnqueueFullScan":
+ _workbench.Indexing.EnqueueFullScan(request.N ?? 0);
+ return reply;
+ case "Indexing.EnqueueFolderScan":
+ _workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
+ return reply;
+ case "Indexing.EnqueueReconcile":
+ _workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
+ return reply;
+ case "Indexing.Cancel":
+ _workbench.Indexing.Cancel(request.N ?? 0);
+ return reply;
+ case "Transfers.Snapshot":
+ reply.Jobs = _workbench.Transfers.Snapshot().ToArray();
+ reply.Paused = _workbench.Transfers.IsPaused;
+ return reply;
+ case "Transfers.IsPaused":
+ reply.Paused = _workbench.Transfers.IsPaused;
+ return reply;
+ case "Transfers.PauseAll":
+ _workbench.Transfers.PauseAll();
+ return reply;
+ case "Transfers.ResumeAll":
+ _workbench.Transfers.ResumeAll();
+ return reply;
+ case "Transfers.Pause":
+ _workbench.Transfers.Pause(request.N ?? 0);
+ return reply;
+ case "Transfers.Resume":
+ _workbench.Transfers.Resume(request.N ?? 0);
+ return reply;
+ case "Transfers.Retry":
+ _workbench.Transfers.Retry(request.N ?? 0);
+ return reply;
+ case "Transfers.Cancel":
+ _workbench.Transfers.Cancel(request.N ?? 0);
+ return reply;
+ case "Transfers.Dismiss":
+ _workbench.Transfers.Dismiss(request.N ?? 0);
+ return reply;
+ case "Transfers.ClearFinished":
+ _workbench.Transfers.ClearFinished();
+ return reply;
+ case "Transfers.MoveUp":
+ reply.Flag = _workbench.Transfers.MoveUp(request.N ?? 0);
+ return reply;
+ case "Transfers.MoveDown":
+ reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0);
+ return reply;
+ default:
+ reply.Ok = false;
+ reply.Error = "Unknown op " + request.Op;
+ return reply;
+ }
+ }
+ catch (Exception ex)
+ {
+ reply.Ok = false;
+ reply.Error = ex.Message;
+ return reply;
+ }
+ }
+
+ private async Task WriteAsync(StreamWriter writer, IpcEnvelope envelope, CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(envelope, WorkbenchIpc.Json);
+ await _write.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _write.Release();
+ }
+ }
+}
diff --git a/src/Explorer.Hosting/WatcherHostedService.cs b/src/Explorer.Hosting/WatcherHostedService.cs
new file mode 100644
index 0000000..e674dae
--- /dev/null
+++ b/src/Explorer.Hosting/WatcherHostedService.cs
@@ -0,0 +1,65 @@
+using Explorer.Application;
+using Explorer.Contracts;
+using Explorer.Domain.Abstractions;
+using Explorer.FileOperations;
+using Explorer.Indexing;
+using Microsoft.Extensions.Hosting;
+
+namespace Explorer.Hosting;
+
+public sealed class WatcherHostedService : BackgroundService
+{
+ private readonly DirectoryWatcherHub _hub;
+ private readonly SourceManager _sources;
+ private readonly TransferQueue _transfers;
+ private readonly FolderSyncService _sync;
+ private readonly OperationProfileService _profiles;
+ private readonly IIndexStore _store;
+ private readonly IIndexingHost _indexing;
+ private readonly UiPreferencesStore _preferences;
+
+ public WatcherHostedService(
+ DirectoryWatcherHub hub,
+ SourceManager sources,
+ TransferQueue transfers,
+ FolderSyncService sync,
+ OperationProfileService profiles,
+ IIndexStore store,
+ IIndexingHost indexing,
+ UiPreferencesStore preferences)
+ {
+ _hub = hub;
+ _sources = sources;
+ _transfers = transfers;
+ _sync = sync;
+ _profiles = profiles;
+ _store = store;
+ _indexing = indexing;
+ _preferences = preferences;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
+ await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
+ while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
+ {
+ await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
+ _transfers.NotifyAvailability();
+ await TryAutoIndexRemovableAsync(stoppingToken).ConfigureAwait(false);
+ await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
+ await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
+ await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task TryAutoIndexRemovableAsync(CancellationToken cancellationToken)
+ {
+ var prefs = _preferences.Load();
+ var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
+ foreach (var sourceId in RemovableAutoIndexPlanner.SourceIdsToScan(sources, prefs.AutoIndexRemovable))
+ {
+ _indexing.EnqueueFullScan(sourceId);
+ }
+ }
+}
diff --git a/src/Explorer.Indexing/Explorer.Indexing.csproj b/src/Explorer.Indexing/Explorer.Indexing.csproj
index 4043f12..f78716a 100644
--- a/src/Explorer.Indexing/Explorer.Indexing.csproj
+++ b/src/Explorer.Indexing/Explorer.Indexing.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/Explorer.Indexing/IndexingCoordinator.cs b/src/Explorer.Indexing/IndexingCoordinator.cs
index f5cb914..14f47f9 100644
--- a/src/Explorer.Indexing/IndexingCoordinator.cs
+++ b/src/Explorer.Indexing/IndexingCoordinator.cs
@@ -1,4 +1,5 @@
using System.Threading.Channels;
+using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
@@ -6,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
-public sealed class IndexingCoordinator : BackgroundService
+public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
{
private readonly IIndexStore _store;
private readonly FilesystemScanner _scanner;
diff --git a/src/Explorer.Presentation/Explorer.Presentation.csproj b/src/Explorer.Presentation/Explorer.Presentation.csproj
index 6646f3f..ec7db5b 100644
--- a/src/Explorer.Presentation/Explorer.Presentation.csproj
+++ b/src/Explorer.Presentation/Explorer.Presentation.csproj
@@ -10,9 +10,9 @@
+
-
diff --git a/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs b/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
index 5eb75bd..a60c858 100644
--- a/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
+using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
-using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
@@ -12,7 +12,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
- private readonly IndexingCoordinator _indexing;
+ private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly IGitStatusProvider _git;
private readonly IThumbnailService? _thumbnails;
@@ -43,7 +43,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
- IndexingCoordinator indexing,
+ IIndexingHost indexing,
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
diff --git a/src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs b/src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
index 6766b9a..fb6c0da 100644
--- a/src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
@@ -1,8 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
+using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
-using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
@@ -22,7 +22,7 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
public ExplorerTabViewModel(
BrowseService browse,
FileOperationService ops,
- IndexingCoordinator indexing,
+ IIndexingHost indexing,
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
diff --git a/src/Explorer.Presentation/ViewModels/MainViewModel.cs b/src/Explorer.Presentation/ViewModels/MainViewModel.cs
index 36511e8..3631991 100644
--- a/src/Explorer.Presentation/ViewModels/MainViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/MainViewModel.cs
@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
+using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
-using Explorer.Indexing;
using Explorer.Search;
using Explorer.Analysis;
using Explorer.Domain.Abstractions;
@@ -16,7 +16,7 @@ public sealed partial class MainViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
- private readonly IndexingCoordinator _indexing;
+ private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
@@ -63,12 +63,11 @@ public sealed partial class MainViewModel : ObservableObject
public MainViewModel(
BrowseService browse,
FileOperationService ops,
- IndexingCoordinator indexing,
SourceManager sources,
SearchService search,
AnalysisService analysis,
IIndexStore store,
- TransferQueue transfers,
+ IWorkbenchHost workbench,
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
@@ -88,7 +87,7 @@ public sealed partial class MainViewModel : ObservableObject
{
_browse = browse;
_ops = ops;
- _indexing = indexing;
+ _indexing = workbench.Indexing;
_sources = sources;
_pathHistory = pathHistory;
_providers = providers;
@@ -112,10 +111,10 @@ public sealed partial class MainViewModel : ObservableObject
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
- Transfers = new TransferQueueViewModel(transfers, preferences);
+ Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
Tabs = [];
Clipboard = clipboard;
- transfers.JobFinished += (_, job) =>
+ workbench.Transfers.JobFinished += (_, job) =>
{
void Go() => _ = OnTransferFinishedAsync(job);
if (_ui is { } ctx)
diff --git a/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs b/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
index 371df24..89de6cf 100644
--- a/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
@@ -2,8 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
+using Explorer.Contracts;
using Explorer.Domain;
-using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
@@ -186,7 +186,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
public sealed partial class TransferQueueViewModel : ObservableObject
{
- private readonly TransferQueue _queue;
+ private readonly ITransferHost _queue;
private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private readonly Dictionary _speed = [];
@@ -205,7 +205,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
[ObservableProperty] private double _overallProgress;
[ObservableProperty] private bool _hasOverallProgress;
- public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences)
+ public TransferQueueViewModel(ITransferHost queue, UiPreferencesStore preferences)
{
_queue = queue;
_preferences = preferences;
diff --git a/src/Explorer.Storage.Sqlite/IndexStoreLock.cs b/src/Explorer.Storage.Sqlite/IndexStoreLock.cs
new file mode 100644
index 0000000..e52bf19
--- /dev/null
+++ b/src/Explorer.Storage.Sqlite/IndexStoreLock.cs
@@ -0,0 +1,88 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Explorer.Storage.Sqlite;
+
+///
+/// Per-user lock so only one process (and one in-process owner) can open the index for write.
+/// GUI and Explorer.Host.exe must not share a writer. Named mutex recovers from a crashed owner;
+/// an in-process table stops the same thread re-entering the mutex.
+///
+public sealed class IndexStoreLock : IDisposable
+{
+ private static readonly ConcurrentDictionary InProcessOwners = new(StringComparer.Ordinal);
+
+ private readonly Mutex _mutex;
+ private readonly string _name;
+ private bool _owned;
+
+ private IndexStoreLock(Mutex mutex, string name)
+ {
+ _mutex = mutex;
+ _name = name;
+ _owned = true;
+ }
+
+ public static string MutexNameFor(string databasePath)
+ {
+ var full = Path.GetFullPath(databasePath);
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(full.ToUpperInvariant())))[..16];
+ return @"Local\ExplorerWorkbench-Index-" + hash;
+ }
+
+ public static IndexStoreLock Acquire(string databasePath, TimeSpan? timeout = null)
+ {
+ var name = MutexNameFor(databasePath);
+ if (!InProcessOwners.TryAdd(name, 0))
+ {
+ throw new InvalidOperationException(
+ "The Explorer Workbench index is already in use by another process.");
+ }
+
+ var mutex = new Mutex(false, name);
+ var wait = timeout ?? TimeSpan.Zero;
+ try
+ {
+ if (!mutex.WaitOne(wait))
+ {
+ mutex.Dispose();
+ InProcessOwners.TryRemove(name, out _);
+ throw new InvalidOperationException(
+ "The Explorer Workbench index is already in use by another process.");
+ }
+ }
+ catch (AbandonedMutexException)
+ {
+ // Previous owner crashed; this process now owns the mutex.
+ }
+ catch
+ {
+ mutex.Dispose();
+ InProcessOwners.TryRemove(name, out _);
+ throw;
+ }
+
+ return new IndexStoreLock(mutex, name);
+ }
+
+ public void Dispose()
+ {
+ if (_owned)
+ {
+ try
+ {
+ _mutex.ReleaseMutex();
+ }
+ catch (ApplicationException)
+ {
+ // Already released or not owned.
+ }
+
+ _owned = false;
+ InProcessOwners.TryRemove(_name, out _);
+ }
+
+ _mutex.Dispose();
+ }
+}
diff --git a/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs b/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs
index 6b785fd..b92f8eb 100644
--- a/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs
+++ b/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs
@@ -13,6 +13,7 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal _writeDepth = new();
private SqliteConnection? _write;
+ private IndexStoreLock? _lock;
private bool _opened;
private int _analysisIndexesReady;
@@ -58,31 +59,48 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return;
}
- DapperSetup.Ensure();
- Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
- _write = new SqliteConnection(BuildConnectionString(_path));
- await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
- ApplyPragmas(_write);
- Migrate(_write);
- if (IndexExists(_write, "ix_entries_dir_agg_all"))
+ _lock = IndexStoreLock.Acquire(_path);
+ try
{
- Volatile.Write(ref _analysisIndexesReady, 1);
- }
+ DapperSetup.Ensure();
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ _write = new SqliteConnection(BuildConnectionString(_path));
+ await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
+ ApplyPragmas(_write);
+ Migrate(_write);
+ if (IndexExists(_write, "ix_entries_dir_agg_all"))
+ {
+ Volatile.Write(ref _analysisIndexesReady, 1);
+ }
- _opened = true;
- _logger.LogInformation("Opened index database at {Path}", _path);
+ _opened = true;
+ _logger.LogInformation("Opened index database at {Path}", _path);
+ }
+ catch
+ {
+ _lock.Dispose();
+ _lock = null;
+ throw;
+ }
}
public async Task CloseAsync()
{
- if (_write is not null)
+ try
{
- await _write.CloseAsync().ConfigureAwait(false);
- await _write.DisposeAsync().ConfigureAwait(false);
- _write = null;
+ if (_write is not null)
+ {
+ await _write.CloseAsync().ConfigureAwait(false);
+ await _write.DisposeAsync().ConfigureAwait(false);
+ _write = null;
+ }
+ }
+ finally
+ {
+ _opened = false;
+ _lock?.Dispose();
+ _lock = null;
}
-
- _opened = false;
}
public async Task QuickCheckAsync(CancellationToken cancellationToken = default)
diff --git a/src/Explorer.Windows/WindowsElevatedScanService.cs b/src/Explorer.Windows/WindowsElevatedScanService.cs
index f9375d4..583267d 100644
--- a/src/Explorer.Windows/WindowsElevatedScanService.cs
+++ b/src/Explorer.Windows/WindowsElevatedScanService.cs
@@ -21,6 +21,7 @@ public sealed class WindowsElevatedScanService : IElevatedScanService
}
}
+ // Queue, index writer, and watchers stay in the user session. Elevation is never requested.
public bool CanRequestElevation => false;
public string ProtectedContentHint => "Protected content requires administrator privileges.";
diff --git a/tests/Explorer.Application.Tests/RemovableAutoIndexPlannerTests.cs b/tests/Explorer.Application.Tests/RemovableAutoIndexPlannerTests.cs
new file mode 100644
index 0000000..5e044c4
--- /dev/null
+++ b/tests/Explorer.Application.Tests/RemovableAutoIndexPlannerTests.cs
@@ -0,0 +1,73 @@
+using Explorer.Application;
+using Explorer.Domain;
+using Explorer.Windows;
+
+namespace Explorer.Application.Tests;
+
+public class RemovableAutoIndexPlannerTests
+{
+ [Fact]
+ public void Does_nothing_when_disabled()
+ {
+ var sources = new[] { Removable(1, indexed: false) };
+ Assert.Empty(RemovableAutoIndexPlanner.SourceIdsToScan(sources, autoIndexRemovable: false));
+ }
+
+ [Fact]
+ public void Queues_online_unindexed_removable_only()
+ {
+ var sources = new[]
+ {
+ Removable(1, indexed: false),
+ Removable(2, indexed: true),
+ Removable(3, indexed: false, status: SourceStatus.Offline),
+ Cloud(4),
+ Local(5)
+ };
+ Assert.Equal(new[] { 1L }, RemovableAutoIndexPlanner.SourceIdsToScan(sources, autoIndexRemovable: true));
+ }
+
+ private static Source Removable(long id, bool indexed, SourceStatus status = SourceStatus.Online)
+ => new()
+ {
+ Id = id,
+ StableKey = id.ToString(),
+ Kind = SourceKind.Removable,
+ DisplayName = "USB",
+ LastRootPath = @"E:\",
+ Status = status,
+ LastIndexedUtc = indexed ? DateTimeOffset.UtcNow : null
+ };
+
+ private static Source Cloud(long id)
+ => new()
+ {
+ Id = id,
+ StableKey = "cloud",
+ Kind = SourceKind.Cloud,
+ DisplayName = "Cloud",
+ LastRootPath = @"C:\Users\me\OneDrive",
+ Status = SourceStatus.Online
+ };
+
+ private static Source Local(long id)
+ => new()
+ {
+ Id = id,
+ StableKey = "local",
+ Kind = SourceKind.NtfsLocal,
+ DisplayName = "Local",
+ LastRootPath = @"C:\",
+ Status = SourceStatus.Online
+ };
+}
+
+public class ElevatedScanServiceTests
+{
+ [Fact]
+ public void Never_requests_elevation()
+ {
+ IElevatedScanService service = new WindowsElevatedScanService();
+ Assert.False(service.CanRequestElevation);
+ }
+}
diff --git a/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs b/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
index e8b75ab..f7ea2c0 100644
--- a/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
+++ b/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
@@ -43,6 +43,18 @@ public class UiPreferencesStoreTests
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
}
+ [Fact]
+ public void Parse_reads_host_and_removable_index_flags()
+ {
+ var prefs = UiPreferencesStore.Parse(
+ [
+ "auto-index-removable=true",
+ "background-host-at-logon=true"
+ ]);
+ Assert.True(prefs.AutoIndexRemovable);
+ Assert.True(prefs.BackgroundHostAtLogon);
+ }
+
[Fact]
public void Parse_defaults_missing_keys()
{
@@ -54,6 +66,8 @@ public class UiPreferencesStoreTests
Assert.True(prefs.ShowHiddenFiles);
Assert.False(prefs.ShowProtectedSystemLocations);
Assert.False(prefs.AutoClearQueueWhenDone);
+ Assert.False(prefs.AutoIndexRemovable);
+ Assert.False(prefs.BackgroundHostAtLogon);
Assert.Null(prefs.SevenZipPath);
Assert.Null(prefs.GitPath);
}
@@ -95,6 +109,8 @@ public class UiPreferencesStoreTests
Assert.True(loaded.ShowHiddenFiles);
Assert.False(loaded.ShowProtectedSystemLocations);
Assert.True(loaded.AutoClearQueueWhenDone);
+ Assert.False(loaded.AutoIndexRemovable);
+ Assert.False(loaded.BackgroundHostAtLogon);
Assert.Equal(1100, loaded.WindowWidth);
Assert.Equal(720, loaded.WindowHeight);
Assert.Equal(300, loaded.TreeWidth);
diff --git a/tests/Explorer.Application.Tests/WorkbenchHostTests.cs b/tests/Explorer.Application.Tests/WorkbenchHostTests.cs
new file mode 100644
index 0000000..8bf7158
--- /dev/null
+++ b/tests/Explorer.Application.Tests/WorkbenchHostTests.cs
@@ -0,0 +1,60 @@
+using Explorer.Application;
+using Explorer.Contracts;
+using Explorer.Domain;
+
+namespace Explorer.Application.Tests;
+
+public class WorkbenchHostTests
+{
+ [Fact]
+ public void Forwards_indexing_and_transfer_sessions()
+ {
+ var indexing = new FakeIndexing();
+ var transfers = new FakeTransfers();
+ IWorkbenchHost host = new WorkbenchHost(indexing, transfers);
+ Assert.Same(indexing, host.Indexing);
+ Assert.Same(transfers, host.Transfers);
+
+ host.Indexing.EnqueueFullScan(7);
+ host.Transfers.PauseAll();
+ Assert.Equal(7, indexing.FullScanId);
+ Assert.True(transfers.PausedAll);
+ }
+
+ private sealed class FakeIndexing : IIndexingHost
+ {
+ public long FullScanId { get; private set; }
+ public event EventHandler? ProgressChanged;
+
+ public void EnqueueFullScan(long sourceId) => FullScanId = sourceId;
+ public void EnqueueFolderScan(long sourceId, string pathRel) { }
+ public void EnqueueReconcile(long sourceId, string pathRel) { }
+ public void Cancel(long sourceId) { }
+
+ public void Raise() => ProgressChanged?.Invoke(this, new ScanProgress
+ {
+ SourceId = 1,
+ CurrentPath = "",
+ Status = ScanJobStatus.Running
+ });
+ }
+
+ private sealed class FakeTransfers : ITransferHost
+ {
+ public bool PausedAll { get; private set; }
+ public event EventHandler? Changed = delegate { };
+ public event EventHandler? JobFinished = delegate { };
+ public bool IsPaused => PausedAll;
+ public IReadOnlyList Snapshot() => [];
+ public void PauseAll() => PausedAll = true;
+ public void ResumeAll() => PausedAll = false;
+ public void Pause(long jobId) { }
+ public void Resume(long jobId) { }
+ public void Retry(long jobId) { }
+ public void Cancel(long jobId) { }
+ public void Dismiss(long jobId) { }
+ public void ClearFinished() { }
+ public bool MoveUp(long jobId) => false;
+ public bool MoveDown(long jobId) => false;
+ }
+}
diff --git a/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs b/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs
new file mode 100644
index 0000000..8da3588
--- /dev/null
+++ b/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs
@@ -0,0 +1,48 @@
+using Explorer.Contracts;
+using Explorer.Domain;
+using Explorer.Domain.Abstractions;
+using Explorer.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Explorer.Hosting.Tests;
+
+public class CoreRegistrationTests
+{
+ [Fact]
+ public async Task AddExplorerCore_registers_workbench_without_clipboard()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ try
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton(new TempEnv(dir));
+ services.AddExplorerCore();
+ await using var sp = services.BuildServiceProvider();
+ Assert.NotNull(sp.GetService());
+ Assert.NotNull(sp.GetService());
+ Assert.NotNull(sp.GetService());
+ Assert.Null(sp.GetService());
+ }
+ finally
+ {
+ try { Directory.Delete(dir, true); } catch { /* ignore */ }
+ }
+ }
+
+ private sealed class TempEnv : IAppEnvironment
+ {
+ public TempEnv(string dir)
+ {
+ DataDirectory = dir;
+ DatabasePath = Path.Combine(dir, "index.db");
+ LogDirectory = Path.Combine(dir, "logs");
+ Directory.CreateDirectory(LogDirectory);
+ }
+
+ public string DataDirectory { get; }
+ public string DatabasePath { get; }
+ public string LogDirectory { get; }
+ }
+}
diff --git a/tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj b/tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj
new file mode 100644
index 0000000..4bd39e7
--- /dev/null
+++ b/tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0-windows
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Explorer.Hosting.Tests/HostLogonAutostartTests.cs b/tests/Explorer.Hosting.Tests/HostLogonAutostartTests.cs
new file mode 100644
index 0000000..c12f73b
--- /dev/null
+++ b/tests/Explorer.Hosting.Tests/HostLogonAutostartTests.cs
@@ -0,0 +1,19 @@
+using Explorer.Hosting;
+
+namespace Explorer.Hosting.Tests;
+
+public class HostLogonAutostartTests
+{
+ [Fact]
+ public void Create_args_are_per_user_logon_not_system_service()
+ {
+ var args = HostLogonAutostart.CreateTaskArgs(@"C:\Tools\Explorer.Host.exe");
+ Assert.Contains("/SC", args);
+ Assert.Contains("ONLOGON", args);
+ Assert.Contains("/RL", args);
+ Assert.Contains("LIMITED", args);
+ Assert.Contains(HostLogonAutostart.TaskName, args);
+ Assert.DoesNotContain("ONSTART", args);
+ Assert.DoesNotContain("/RU", args);
+ }
+}
diff --git a/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs b/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs
new file mode 100644
index 0000000..147c385
--- /dev/null
+++ b/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs
@@ -0,0 +1,76 @@
+using Explorer.Application;
+using Explorer.Contracts;
+using Explorer.Domain;
+using Explorer.Hosting.Ipc;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Explorer.Hosting.Tests;
+
+public class WorkbenchPipeTests
+{
+ [Fact]
+ public void Handle_forwards_indexing_and_transfer_calls()
+ {
+ var indexing = new FakeIndexing();
+ var transfers = new FakeTransfers();
+ var server = new WorkbenchPipeServer(
+ new WorkbenchHost(indexing, transfers),
+ new WorkbenchIpcOptions { PipeName = "ew-test" },
+ NullLogger.Instance);
+
+ var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
+ Assert.True(ping.Ok);
+
+ var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 });
+ Assert.True(scan.Ok);
+ Assert.Equal(42, indexing.FullScanId);
+
+ var pause = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.PauseAll" });
+ Assert.True(pause.Ok);
+ Assert.True(transfers.PausedAll);
+
+ var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" });
+ Assert.True(paused.Paused);
+ }
+
+ [Fact]
+ public void Handle_rejects_other_protocol_versions()
+ {
+ var server = new WorkbenchPipeServer(
+ new WorkbenchHost(new FakeIndexing(), new FakeTransfers()),
+ new WorkbenchIpcOptions(),
+ NullLogger.Instance);
+ var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
+ Assert.False(reply.Ok);
+ Assert.Contains("99", reply.Error);
+ }
+
+ private sealed class FakeIndexing : IIndexingHost
+ {
+ public long FullScanId { get; private set; }
+ public event EventHandler? ProgressChanged = delegate { };
+ public void EnqueueFullScan(long sourceId) => FullScanId = sourceId;
+ public void EnqueueFolderScan(long sourceId, string pathRel) { }
+ public void EnqueueReconcile(long sourceId, string pathRel) { }
+ public void Cancel(long sourceId) { }
+ }
+
+ private sealed class FakeTransfers : ITransferHost
+ {
+ public bool PausedAll { get; private set; }
+ public event EventHandler? Changed = delegate { };
+ public event EventHandler? JobFinished = delegate { };
+ public bool IsPaused => PausedAll;
+ public IReadOnlyList Snapshot() => [];
+ public void PauseAll() => PausedAll = true;
+ public void ResumeAll() => PausedAll = false;
+ public void Pause(long jobId) { }
+ public void Resume(long jobId) { }
+ public void Retry(long jobId) { }
+ public void Cancel(long jobId) { }
+ public void Dismiss(long jobId) { }
+ public void ClearFinished() { }
+ public bool MoveUp(long jobId) => false;
+ public bool MoveDown(long jobId) => false;
+ }
+}
diff --git a/tests/Explorer.Storage.Tests/StorageTests.cs b/tests/Explorer.Storage.Tests/StorageTests.cs
index 6ab2116..d39c2e6 100644
--- a/tests/Explorer.Storage.Tests/StorageTests.cs
+++ b/tests/Explorer.Storage.Tests/StorageTests.cs
@@ -427,3 +427,28 @@ public class FileRelationTests
Assert.Empty(await store.OperationProfiles.ListAsync());
}
}
+
+public class IndexStoreLockTests
+{
+ [Fact]
+ public async Task Second_writer_on_same_path_is_rejected()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
+ await using var first = new SqliteIndexStore(path, NullLogger.Instance);
+ await first.OpenAsync();
+ var second = new SqliteIndexStore(path, NullLogger.Instance);
+ var ex = await Assert.ThrowsAsync(() => second.OpenAsync());
+ Assert.Contains("already in use", ex.Message, StringComparison.OrdinalIgnoreCase);
+ await first.DisposeAsync();
+ await second.OpenAsync();
+ await second.DisposeAsync();
+ }
+
+ [Fact]
+ public void Mutex_name_is_stable_for_the_same_path()
+ {
+ var path = @"C:\Users\me\AppData\Local\ExplorerWorkbench\index.db";
+ Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path));
+ Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path));
+ }
+}