From 7c23bc2474fadfc2ac576774fd143a8dbfa5971a Mon Sep 17 00:00:00 2001 From: netquick Date: Mon, 24 Aug 2026 17:10:49 +0200 Subject: [PATCH] Extract a per-user background host so indexing can later run outside the window. Keep the GUI as the index writer for now; mutex, named pipe, and opt-in logon autostart prepare Explorer.Host.exe without two SQLite writers. Co-authored-by: Cursor --- Explorer.slnx | 3 + src/Explorer.App/AppServices.cs | 111 +------ src/Explorer.App/Explorer.App.csproj | 17 ++ src/Explorer.App/SettingsWindow.xaml | 10 + src/Explorer.App/SettingsWindow.xaml.cs | 36 +++ .../IElevatedScanService.cs | 5 + .../RemovableAutoIndexPlanner.cs | 23 ++ .../UiPreferencesStore.cs | 18 +- src/Explorer.Application/WorkbenchHost.cs | 15 + src/Explorer.Contracts/IWorkbenchHost.cs | 36 +++ .../Explorer.FileOperations.csproj | 1 + src/Explorer.FileOperations/TransferQueue.cs | 3 +- src/Explorer.Host/Explorer.Host.csproj | 21 ++ src/Explorer.Host/Program.cs | 37 +++ src/Explorer.Hosting/Explorer.Hosting.csproj | 29 ++ src/Explorer.Hosting/ExplorerHostServices.cs | 100 +++++++ src/Explorer.Hosting/HostLogonAutostart.cs | 109 +++++++ src/Explorer.Hosting/IndexStoreLifetime.cs | 15 + src/Explorer.Hosting/Ipc/WorkbenchIpc.cs | 48 +++ .../Ipc/WorkbenchPipeClient.cs | 276 ++++++++++++++++++ .../Ipc/WorkbenchPipeServer.cs | 233 +++++++++++++++ src/Explorer.Hosting/WatcherHostedService.cs | 65 +++++ .../Explorer.Indexing.csproj | 1 + src/Explorer.Indexing/IndexingCoordinator.cs | 3 +- .../Explorer.Presentation.csproj | 2 +- .../ViewModels/ExplorerPaneViewModel.cs | 6 +- .../ViewModels/ExplorerTabViewModel.cs | 4 +- .../ViewModels/MainViewModel.cs | 13 +- .../ViewModels/TransferQueueViewModel.cs | 6 +- src/Explorer.Storage.Sqlite/IndexStoreLock.cs | 88 ++++++ .../SqliteIndexStore.cs | 52 ++-- .../WindowsElevatedScanService.cs | 1 + .../RemovableAutoIndexPlannerTests.cs | 73 +++++ .../UiPreferencesStoreTests.cs | 16 + .../WorkbenchHostTests.cs | 60 ++++ .../CoreRegistrationTests.cs | 48 +++ .../Explorer.Hosting.Tests.csproj | 22 ++ .../HostLogonAutostartTests.cs | 19 ++ .../WorkbenchPipeTests.cs | 76 +++++ tests/Explorer.Storage.Tests/StorageTests.cs | 25 ++ 40 files changed, 1586 insertions(+), 140 deletions(-) create mode 100644 src/Explorer.Application/RemovableAutoIndexPlanner.cs create mode 100644 src/Explorer.Application/WorkbenchHost.cs create mode 100644 src/Explorer.Contracts/IWorkbenchHost.cs create mode 100644 src/Explorer.Host/Explorer.Host.csproj create mode 100644 src/Explorer.Host/Program.cs create mode 100644 src/Explorer.Hosting/Explorer.Hosting.csproj create mode 100644 src/Explorer.Hosting/ExplorerHostServices.cs create mode 100644 src/Explorer.Hosting/HostLogonAutostart.cs create mode 100644 src/Explorer.Hosting/IndexStoreLifetime.cs create mode 100644 src/Explorer.Hosting/Ipc/WorkbenchIpc.cs create mode 100644 src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs create mode 100644 src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs create mode 100644 src/Explorer.Hosting/WatcherHostedService.cs create mode 100644 src/Explorer.Storage.Sqlite/IndexStoreLock.cs create mode 100644 tests/Explorer.Application.Tests/RemovableAutoIndexPlannerTests.cs create mode 100644 tests/Explorer.Application.Tests/WorkbenchHostTests.cs create mode 100644 tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs create mode 100644 tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj create mode 100644 tests/Explorer.Hosting.Tests/HostLogonAutostartTests.cs create mode 100644 tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs 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)); + } +}