From 48d03f794f60f8a22ac5e626940eb0f11c4482eb Mon Sep 17 00:00:00 2001 From: netquick Date: Mon, 24 Aug 2026 17:39:13 +0200 Subject: [PATCH] Cut over the window to Explorer.Host.exe so only the host writes the index. Co-authored-by: Cursor --- src/Explorer.App/App.xaml.cs | 36 +++++++- src/Explorer.App/SettingsWindow.xaml | 2 +- src/Explorer.Application/SourceManager.cs | 69 +++++++++++++- src/Explorer.Application/WorkbenchHost.cs | 45 ++++++++- src/Explorer.Contracts/IWorkbenchHost.cs | 40 ++++++++ .../Abstractions/IIndexStore.cs | 2 + .../FileOperationService.cs | 6 +- .../FolderSyncService.cs | 12 ++- .../OperationProfileService.cs | 14 ++- .../RenameBatchService.cs | 11 ++- src/Explorer.Hosting/ExplorerHostServices.cs | 74 +++++++++++---- src/Explorer.Hosting/IndexStoreLifetime.cs | 14 ++- src/Explorer.Hosting/Ipc/WorkbenchIpc.cs | 4 + .../Ipc/WorkbenchPipeClient.cs | 87 +++++++++++++++++- .../Ipc/WorkbenchPipeServer.cs | 72 ++++++++++++++- .../WorkbenchHostConnector.cs | 58 ++++++++++++ .../ViewModels/DuplicateViewModel.cs | 10 +- .../ViewModels/MainViewModel.cs | 4 +- .../SqliteIndexStore.cs | 75 ++++++++++++++- .../WorkbenchHostTests.cs | 32 ++++++- .../FolderSyncServiceTests.cs | 2 + .../OperationProfileServiceTests.cs | 4 +- .../TransferQueueTests.cs | 2 +- .../CoreRegistrationTests.cs | 92 +++++++++++++++++++ .../WorkbenchPipeTests.cs | 46 +++++++++- tests/Explorer.Storage.Tests/StorageTests.cs | 20 ++++ 26 files changed, 774 insertions(+), 59 deletions(-) create mode 100644 src/Explorer.Hosting/WorkbenchHostConnector.cs diff --git a/src/Explorer.App/App.xaml.cs b/src/Explorer.App/App.xaml.cs index 2a3edc5..5c0a0ef 100644 --- a/src/Explorer.App/App.xaml.cs +++ b/src/Explorer.App/App.xaml.cs @@ -1,5 +1,7 @@ using System.IO; using System.Windows; +using Explorer.Hosting; +using Explorer.Hosting.Ipc; using Explorer.Presentation.ViewModels; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -10,6 +12,7 @@ namespace Explorer.App; public partial class App : System.Windows.Application { private IHost? _host; + private WorkbenchPipeClient? _workbenchClient; protected override async void OnStartup(StartupEventArgs e) { @@ -29,9 +32,35 @@ public partial class App : System.Windows.Application retainedFileCountLimit: 14) .CreateLogger(); + _workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync( + TimeSpan.FromSeconds(12), + logger: null).ConfigureAwait(true); + var hostExe = HostLogonAutostart.FindHostExecutable(); + if (_workbenchClient is null && hostExe is not null) + { + MessageBox.Show( + "Explorer.Host.exe is present but the window could not connect to it. See logs.", + "Explorer Workbench", + MessageBoxButton.OK, + MessageBoxImage.Error); + Shutdown(-1); + return; + } + _host = Host.CreateDefaultBuilder() .UseSerilog() - .ConfigureServices((_, services) => services.AddExplorer()) + .ConfigureServices((_, services) => + { + if (_workbenchClient is not null) + { + services.AddExplorerClient(_workbenchClient); + services.AddExplorerUi(); + } + else + { + services.AddExplorer(); + } + }) .Build(); var vm = _host.Services.GetRequiredService(); @@ -60,6 +89,11 @@ public partial class App : System.Windows.Application _host.Dispose(); } + if (_workbenchClient is not null) + { + await _workbenchClient.DisposeAsync().ConfigureAwait(true); + } + Log.CloseAndFlush(); base.OnExit(e); } diff --git a/src/Explorer.App/SettingsWindow.xaml b/src/Explorer.App/SettingsWindow.xaml index 140282f..53ac470 100644 --- a/src/Explorer.App/SettingsWindow.xaml +++ b/src/Explorer.App/SettingsWindow.xaml @@ -70,7 +70,7 @@ + Text="Registers a per-user logon task. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write."/> _logger; + private readonly ISourceHost? _remote; private readonly object _refreshLock = new(); private Task>? _refreshInFlight; @@ -23,18 +25,31 @@ public sealed class SourceManager IVolumeService volumes, IAppEnvironment env, IClock clock, - ILogger logger) + ILogger logger, + ISourceHost? remote = null) { _store = store; _volumes = volumes; _env = env; _clock = clock; _logger = logger; + _remote = remote; } public async Task InitializeAsync(CancellationToken cancellationToken = default) { await _store.OpenAsync(cancellationToken).ConfigureAwait(false); + if (!_store.CanWrite) + { + if (_remote is not null) + { + await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false); + } + + InvalidateRefreshCache(); + return; + } + await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false); await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false); await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false); @@ -70,6 +85,22 @@ public sealed class SourceManager private async Task> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken) { + if (!_store.CanWrite) + { + if (_remote is not null) + { + await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false); + } + + var snapshot = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false); + lock (_refreshLock) + { + _refreshCacheTimestamp = Stopwatch.GetTimestamp(); + } + + return snapshot; + } + var started = Stopwatch.GetTimestamp(); var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList(); var online = _volumes.EnumerateOnlineVolumes(); @@ -201,6 +232,18 @@ public sealed class SourceManager public async Task AddUncAsync(string path, CancellationToken cancellationToken = default) { + if (!_store.CanWrite) + { + if (_remote is null) + { + throw new InvalidOperationException("Cannot add a network location while the index is read-only."); + } + + var added = await _remote.AddUncAsync(path, cancellationToken).ConfigureAwait(false); + InvalidateRefreshCache(); + return added; + } + var root = PathRules.CanonicalUncRoot(path); var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false); var fp = new VolumeFingerprint @@ -269,6 +312,18 @@ public sealed class SourceManager public async Task ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default) { + if (!_store.CanWrite) + { + if (_remote is null) + { + return false; + } + + var forgotten = await _remote.ForgetAsync(path, cancellationToken).ConfigureAwait(false); + InvalidateRefreshCache(); + return forgotten; + } + var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false); if (source is null || !CanForget(source)) { @@ -324,6 +379,18 @@ public sealed class SourceManager return null; } + if (!_store.CanWrite) + { + if (_remote is null) + { + return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false); + } + + var ensured = await _remote.EnsureForPathAsync(path, cancellationToken).ConfigureAwait(false); + InvalidateRefreshCache(); + return ensured; + } + var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false); if (existing is not null) { diff --git a/src/Explorer.Application/WorkbenchHost.cs b/src/Explorer.Application/WorkbenchHost.cs index 11156eb..a9fbd92 100644 --- a/src/Explorer.Application/WorkbenchHost.cs +++ b/src/Explorer.Application/WorkbenchHost.cs @@ -1,15 +1,58 @@ using Explorer.Contracts; +using Explorer.Domain; +using Explorer.Domain.Abstractions; namespace Explorer.Application; public sealed class WorkbenchHost : IWorkbenchHost { - public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers) + public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers, ISourceHost sources, IIndexMutations mutations) { Indexing = indexing; Transfers = transfers; + Sources = sources; + Mutations = mutations; } public IIndexingHost Indexing { get; } public ITransferHost Transfers { get; } + public ISourceHost Sources { get; } + public IIndexMutations Mutations { get; } +} + +public sealed class LocalSourceHost : ISourceHost +{ + private readonly SourceManager _sources; + public LocalSourceHost(SourceManager sources) => _sources = sources; + public Task RefreshAsync(CancellationToken cancellationToken = default) + => _sources.RefreshOnlineStateAsync(forceRefresh: true, cancellationToken); + public Task AddUncAsync(string path, CancellationToken cancellationToken = default) + => _sources.AddUncAsync(path, cancellationToken); + public Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default) + => _sources.EnsureForPathAsync(path, cancellationToken); + public Task ForgetAsync(string path, CancellationToken cancellationToken = default) + => _sources.ForgetDisconnectedAsync(path, cancellationToken); +} + +public sealed class LocalIndexMutations : IIndexMutations +{ + private readonly IIndexStore _store; + public LocalIndexMutations(IIndexStore store) => _store = store; + + public Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default) + => _store.SyncProfiles.UpsertAsync(profile, cancellationToken); + public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) + => _store.SyncProfiles.DeleteAsync(id, cancellationToken); + public Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default) + => _store.OperationProfiles.UpsertAsync(profile, cancellationToken); + public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) + => _store.OperationProfiles.DeleteAsync(id, cancellationToken); + public Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default) + => _store.RenameBatches.CreateAsync(items, cancellationToken); + public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) + => _store.RenameBatches.MarkUndoneAsync(id, cancellationToken); + public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) + => _store.Hashes.EnqueueSizeCollisionsAsync(sourceId, cancellationToken); + public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) + => _store.Relations.UpsertAsync(relation, cancellationToken); } diff --git a/src/Explorer.Contracts/IWorkbenchHost.cs b/src/Explorer.Contracts/IWorkbenchHost.cs index d38cca1..1442a29 100644 --- a/src/Explorer.Contracts/IWorkbenchHost.cs +++ b/src/Explorer.Contracts/IWorkbenchHost.cs @@ -6,6 +6,8 @@ public interface IWorkbenchHost { IIndexingHost Indexing { get; } ITransferHost Transfers { get; } + ISourceHost Sources { get; } + IIndexMutations Mutations { get; } } public interface IIndexingHost @@ -33,4 +35,42 @@ public interface ITransferHost void ClearFinished(); bool MoveUp(long jobId); bool MoveDown(long jobId); + Task EnqueueCopyAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueMoveAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueDeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueCompressAsync(IReadOnlyList sources, string archivePath, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList sources, CancellationToken cancellationToken = default) + => Task.CompletedTask; + Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public interface ISourceHost +{ + Task RefreshAsync(CancellationToken cancellationToken = default); + Task AddUncAsync(string path, CancellationToken cancellationToken = default); + Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default); + Task ForgetAsync(string path, CancellationToken cancellationToken = default); +} + +public interface IIndexMutations +{ + Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default); + Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default); + Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default); + Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default); + Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default); + Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default); + Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default); + Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default); } diff --git a/src/Explorer.Domain/Abstractions/IIndexStore.cs b/src/Explorer.Domain/Abstractions/IIndexStore.cs index 6ffed02..4611da1 100644 --- a/src/Explorer.Domain/Abstractions/IIndexStore.cs +++ b/src/Explorer.Domain/Abstractions/IIndexStore.cs @@ -20,6 +20,8 @@ public interface IIndexStore ISyncProfileStore SyncProfiles { get; } IOperationProfileStore OperationProfiles { get; } + bool CanWrite { get; } + Task RunWriteAsync(Func work, CancellationToken cancellationToken = default); Task RunWriteAsync(Func> work, CancellationToken cancellationToken = default); } diff --git a/src/Explorer.FileOperations/FileOperationService.cs b/src/Explorer.FileOperations/FileOperationService.cs index 05161db..19cafb9 100644 --- a/src/Explorer.FileOperations/FileOperationService.cs +++ b/src/Explorer.FileOperations/FileOperationService.cs @@ -1,16 +1,16 @@ +using Explorer.Contracts; using Explorer.Domain; using Explorer.Domain.Abstractions; -using Explorer.FileOperations; namespace Explorer.FileOperations; public sealed class FileOperationService { - private readonly TransferQueue _queue; + private readonly ITransferHost _queue; private readonly IShellFileOperations _shell; private readonly IFileSystemEnumerator _enumerator; - public FileOperationService(TransferQueue queue, IShellFileOperations shell, IFileSystemEnumerator enumerator) + public FileOperationService(ITransferHost queue, IShellFileOperations shell, IFileSystemEnumerator enumerator) { _queue = queue; _shell = shell; diff --git a/src/Explorer.FileOperations/FolderSyncService.cs b/src/Explorer.FileOperations/FolderSyncService.cs index 568f5a2..ccd2f01 100644 --- a/src/Explorer.FileOperations/FolderSyncService.cs +++ b/src/Explorer.FileOperations/FolderSyncService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Explorer.Application; +using Explorer.Contracts; using Explorer.Domain; using Explorer.Domain.Abstractions; @@ -9,6 +10,7 @@ public sealed class FolderSyncService { private readonly FolderSyncPlanner _planner; private readonly IIndexStore _store; + private readonly IIndexMutations _mutations; private readonly SourceManager _sources; private readonly FileOperationService _ops; private readonly IVolumeService _volumes; @@ -20,6 +22,7 @@ public sealed class FolderSyncService public FolderSyncService( FolderSyncPlanner planner, IIndexStore store, + IIndexMutations mutations, SourceManager sources, FileOperationService ops, IVolumeService volumes, @@ -28,6 +31,7 @@ public sealed class FolderSyncService { _planner = planner; _store = store; + _mutations = mutations; _sources = sources; _ops = ops; _volumes = volumes; @@ -46,12 +50,12 @@ public sealed class FolderSyncService } AttachVolumeGuids(profile); - profile.Id = await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); + profile.Id = await _mutations.UpsertSyncProfileAsync(profile, cancellationToken).ConfigureAwait(false); return profile.Id; } public Task DeleteAsync(long id, CancellationToken cancellationToken = default) - => _store.SyncProfiles.DeleteAsync(id, cancellationToken); + => _mutations.DeleteSyncProfileAsync(id, cancellationToken); public async Task PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default) { @@ -97,7 +101,7 @@ public sealed class FolderSyncService profile.LastStatus = deletes > 0 ? $"Queued {copies} copy, {deletes} delete" : $"Queued {copies} copy"; - await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); + await _mutations.UpsertSyncProfileAsync(profile, cancellationToken).ConfigureAwait(false); return plan; } @@ -171,7 +175,7 @@ public sealed class FolderSyncService return; } - await _store.Relations.UpsertAsync(new FileRelation + await _mutations.UpsertRelationAsync(new FileRelation { LeftEntryId = left.Id, RightEntryId = right.Id, diff --git a/src/Explorer.FileOperations/OperationProfileService.cs b/src/Explorer.FileOperations/OperationProfileService.cs index 1ddd9c3..74e19fe 100644 --- a/src/Explorer.FileOperations/OperationProfileService.cs +++ b/src/Explorer.FileOperations/OperationProfileService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Explorer.Application; +using Explorer.Contracts; using Explorer.Domain; using Explorer.Domain.Abstractions; @@ -9,6 +10,7 @@ public sealed class OperationProfileService { private readonly FileOperationProfilePlanner _planner; private readonly IIndexStore _store; + private readonly IIndexMutations _mutations; private readonly FileOperationService _ops; private readonly RenameBatchService _renames; private readonly IVolumeService _volumes; @@ -21,6 +23,7 @@ public sealed class OperationProfileService public OperationProfileService( FileOperationProfilePlanner planner, IIndexStore store, + IIndexMutations mutations, FileOperationService ops, RenameBatchService renames, IVolumeService volumes, @@ -31,6 +34,7 @@ public sealed class OperationProfileService { _planner = planner; _store = store; + _mutations = mutations; _ops = ops; _renames = renames; _volumes = volumes; @@ -55,12 +59,12 @@ public sealed class OperationProfileService AttachVolumeGuids(profile); profile.AutoRun = profile.CanAutoRun; - profile.Id = await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); + profile.Id = await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false); return profile.Id; } public Task DeleteAsync(long id, CancellationToken cancellationToken = default) - => _store.OperationProfiles.DeleteAsync(id, cancellationToken); + => _mutations.DeleteOperationProfileAsync(id, cancellationToken); public async Task DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default) { @@ -150,7 +154,7 @@ public sealed class OperationProfileService var renamed = renames.Count; profile.LastRunUtc = DateTimeOffset.UtcNow; profile.LastStatus = $"Queued {copies} copy, {compress} compress, {renamed} rename"; - await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); + await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false); return plan; } @@ -206,7 +210,7 @@ public sealed class OperationProfileService } var now = DateTimeOffset.UtcNow; - await _store.OperationProfiles.UpsertAsync(new OperationProfile + await _mutations.UpsertOperationProfileAsync(new OperationProfile { Name = "Archive folder", RequireGitClean = true, @@ -216,7 +220,7 @@ public sealed class OperationProfileService IsBuiltIn = true, CreatedUtc = now }, cancellationToken).ConfigureAwait(false); - await _store.OperationProfiles.UpsertAsync(new OperationProfile + await _mutations.UpsertOperationProfileAsync(new OperationProfile { Name = "Copy to destination", DoCopy = true, diff --git a/src/Explorer.FileOperations/RenameBatchService.cs b/src/Explorer.FileOperations/RenameBatchService.cs index 9ea54b3..25ab300 100644 --- a/src/Explorer.FileOperations/RenameBatchService.cs +++ b/src/Explorer.FileOperations/RenameBatchService.cs @@ -1,4 +1,5 @@ using Explorer.Application; +using Explorer.Contracts; using Explorer.Domain; using Explorer.Domain.Abstractions; @@ -8,12 +9,14 @@ public sealed class RenameBatchService { private readonly RenamePlanner _planner; private readonly IIndexStore _store; + private readonly IIndexMutations _mutations; private readonly FileOperationService _ops; - public RenameBatchService(RenamePlanner planner, IIndexStore store, FileOperationService ops) + public RenameBatchService(RenamePlanner planner, IIndexStore store, IIndexMutations mutations, FileOperationService ops) { _planner = planner; _store = store; + _mutations = mutations; _ops = ops; } @@ -36,7 +39,7 @@ public sealed class RenameBatchService var items = plan.Operations .Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i)) .ToList(); - var batchId = await _store.RenameBatches.CreateAsync(items, cancellationToken).ConfigureAwait(false); + var batchId = await _mutations.CreateRenameBatchAsync(items, cancellationToken).ConfigureAwait(false); await _ops.EnqueueRenameAsync( plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(), cancellationToken) @@ -63,7 +66,7 @@ public sealed class RenameBatchService { if (!plan.HasErrors) { - await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); + await _mutations.MarkRenameBatchUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); } return plan; @@ -73,7 +76,7 @@ public sealed class RenameBatchService plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(), cancellationToken) .ConfigureAwait(false); - await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); + await _mutations.MarkRenameBatchUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); return plan; } } diff --git a/src/Explorer.Hosting/ExplorerHostServices.cs b/src/Explorer.Hosting/ExplorerHostServices.cs index f13df25..b40b314 100644 --- a/src/Explorer.Hosting/ExplorerHostServices.cs +++ b/src/Explorer.Hosting/ExplorerHostServices.cs @@ -23,6 +23,14 @@ namespace Explorer.Hosting; public static class ExplorerHostServices { public static IServiceCollection AddExplorerCore(this IServiceCollection services) + { + services.AddExplorerShared(readOnly: false); + services.AddExplorerWorkers(); + services.AddExplorerOperations(); + return services; + } + + public static IServiceCollection AddExplorerShared(this IServiceCollection services, bool readOnly) { services.TryAddSingleton(); services.TryAddSingleton(); @@ -34,7 +42,7 @@ public static class ExplorerHostServices { var env = sp.GetRequiredService(); var logger = sp.GetRequiredService>(); - return new SqliteIndexStore(env.DatabasePath, logger); + return new SqliteIndexStore(env.DatabasePath, logger, readOnly); }); services.AddSingleton(); services.AddSingleton(); @@ -47,7 +55,13 @@ public static class ExplorerHostServices services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(sp => new SourceManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -57,24 +71,26 @@ public static class ExplorerHostServices services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IServiceCollection AddExplorerWorkers(this IServiceCollection services) + { + 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(sp => new LocalIndexMutations(sp.GetRequiredService())); + services.AddSingleton(sp => new WorkbenchHost( + sp.GetRequiredService(), + sp.GetRequiredService(), + new LocalSourceHost(sp.GetRequiredService()), + sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); @@ -85,10 +101,32 @@ public static class ExplorerHostServices 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 AddExplorerOperations(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IServiceCollection AddExplorerClient(this IServiceCollection services, IWorkbenchHost workbench) + { + services.AddSingleton(workbench); + services.AddSingleton(workbench.Indexing); + services.AddSingleton(workbench.Transfers); + services.AddSingleton(workbench.Sources); + services.AddSingleton(workbench.Mutations); + services.AddExplorerShared(readOnly: true); + services.AddExplorerOperations(); + services.AddHostedService(); + return services; + } + public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services) { services.TryAddSingleton(); diff --git a/src/Explorer.Hosting/IndexStoreLifetime.cs b/src/Explorer.Hosting/IndexStoreLifetime.cs index dabfc75..bee781b 100644 --- a/src/Explorer.Hosting/IndexStoreLifetime.cs +++ b/src/Explorer.Hosting/IndexStoreLifetime.cs @@ -1,3 +1,4 @@ +using Explorer.Application; using Explorer.Domain.Abstractions; using Microsoft.Extensions.Hosting; @@ -6,10 +7,19 @@ namespace Explorer.Hosting; public sealed class IndexStoreLifetime : IHostedService { private readonly IIndexStore _store; + private readonly SourceManager _sources; - public IndexStoreLifetime(IIndexStore store) => _store = store; + public IndexStoreLifetime(IIndexStore store, SourceManager sources) + { + _store = store; + _sources = sources; + } - public Task StartAsync(CancellationToken cancellationToken) => _store.OpenAsync(cancellationToken); + public async Task StartAsync(CancellationToken cancellationToken) + { + await _store.OpenAsync(cancellationToken).ConfigureAwait(false); + await _sources.InitializeAsync(cancellationToken).ConfigureAwait(false); + } public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync(); } diff --git a/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs b/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs index 5e7a58c..a4fce96 100644 --- a/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs +++ b/src/Explorer.Hosting/Ipc/WorkbenchIpc.cs @@ -42,7 +42,11 @@ internal sealed class IpcEnvelope public string? S { get; set; } public bool? Flag { get; set; } public bool? Paused { get; set; } + public string? Dest { get; set; } + public string[]? Paths { get; set; } + public string? Payload { get; set; } public ScanProgress? Progress { get; set; } public TransferJob? Job { get; set; } public TransferJob[]? Jobs { get; set; } + public Source? Source { get; set; } } diff --git a/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs b/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs index 270f1e1..1ac26b1 100644 --- a/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs +++ b/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs @@ -18,6 +18,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable private readonly Task _readLoop; private readonly IndexingProxy _indexing; private readonly TransferProxy _transfers; + private readonly SourceProxy _sources; + private readonly MutationProxy _mutations; private WorkbenchPipeClient(NamedPipeClientStream pipe) { @@ -26,11 +28,15 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable _reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); _indexing = new IndexingProxy(this); _transfers = new TransferProxy(this); + _sources = new SourceProxy(this); + _mutations = new MutationProxy(this); _readLoop = ReadLoopAsync(_cts.Token); } public IIndexingHost Indexing => _indexing; public ITransferHost Transfers => _transfers; + public ISourceHost Sources => _sources; + public IIndexMutations Mutations => _mutations; public static async Task ConnectAsync( WorkbenchIpcOptions options, @@ -86,12 +92,27 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable string op, CancellationToken cancellationToken, long? n = null, - string? s = null) + string? s = null, + string? dest = null, + string[]? paths = null, + bool? flag = null, + string? payload = 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 request = new IpcEnvelope + { + V = WorkbenchIpc.ProtocolVersion, + Id = id, + Op = op, + N = n, + S = s, + Dest = dest, + Paths = paths, + Flag = flag, + Payload = payload + }; var json = JsonSerializer.Serialize(request, WorkbenchIpc.Json); await _send.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -270,7 +291,69 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable 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 Task EnqueueCopyAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray()); + public Task EnqueueMoveAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray()); + public Task EnqueueDeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent); + public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueRename", cancellationToken, s: path, dest: newName); + public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueEmptyRecycleBin", cancellationToken); + public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueExtract", cancellationToken, s: archivePath, dest: destinationDirectory); + public Task EnqueueCompressAsync(IReadOnlyList sources, string archivePath, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueCompress", cancellationToken, dest: archivePath, paths: sources.ToArray()); + public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList sources, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueAddToArchive", cancellationToken, dest: archivePath, paths: sources.ToArray()); + public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default) + => _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath); public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job); } + + private sealed class SourceProxy : ISourceHost + { + private readonly WorkbenchPipeClient _client; + public SourceProxy(WorkbenchPipeClient client) => _client = client; + + public Task RefreshAsync(CancellationToken cancellationToken = default) + => _client.CallAsync("Sources.Refresh", cancellationToken); + public async Task AddUncAsync(string path, CancellationToken cancellationToken = default) + => (await _client.CallAsync("Sources.AddUnc", cancellationToken, s: path).ConfigureAwait(false)).Source + ?? throw new InvalidOperationException("Host did not return a source."); + public Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default) + => CallSource("Sources.EnsureForPath", path, cancellationToken); + public async Task ForgetAsync(string path, CancellationToken cancellationToken = default) + => (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true; + + private async Task CallSource(string op, string path, CancellationToken cancellationToken) + => (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source; + } + + private sealed class MutationProxy : IIndexMutations + { + private readonly WorkbenchPipeClient _client; + public MutationProxy(WorkbenchPipeClient client) => _client = client; + + public async Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default) + => (await _client.CallAsync("Mutations.UpsertSyncProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0; + public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) + => _client.CallAsync("Mutations.DeleteSyncProfile", cancellationToken, n: id); + public async Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default) + => (await _client.CallAsync("Mutations.UpsertOperationProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0; + public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) + => _client.CallAsync("Mutations.DeleteOperationProfile", cancellationToken, n: id); + public async Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default) + => (await _client.CallAsync("Mutations.CreateRenameBatch", cancellationToken, payload: Json(items)).ConfigureAwait(false)).N ?? 0; + public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) + => _client.CallAsync("Mutations.MarkRenameBatchUndone", cancellationToken, n: id); + public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) + => _client.CallAsync("Mutations.EnqueueHashCollisions", cancellationToken, n: sourceId ?? 0); + public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) + => _client.CallAsync("Mutations.UpsertRelation", cancellationToken, payload: Json(relation)); + + private static string Json(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json); + } } diff --git a/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs b/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs index 65378f8..e43ce18 100644 --- a/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs +++ b/src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs @@ -126,7 +126,7 @@ public sealed class WorkbenchPipeServer : BackgroundService continue; } - var response = Handle(request); + var response = await HandleAsync(request).ConfigureAwait(false); await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false); } } @@ -139,6 +139,9 @@ public sealed class WorkbenchPipeServer : BackgroundService } internal IpcEnvelope Handle(IpcEnvelope request) + => HandleAsync(request).GetAwaiter().GetResult(); + + internal async Task HandleAsync(IpcEnvelope request) { var reply = new IpcEnvelope { Id = request.Id, Ok = true }; if (request.V != WorkbenchIpc.ProtocolVersion) @@ -203,6 +206,69 @@ public sealed class WorkbenchPipeServer : BackgroundService case "Transfers.MoveDown": reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0); return reply; + case "Transfers.EnqueueCopy": + await _workbench.Transfers.EnqueueCopyAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false); + return reply; + case "Transfers.EnqueueMove": + await _workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false); + return reply; + case "Transfers.EnqueueDelete": + await _workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false); + return reply; + case "Transfers.EnqueueRename": + await _workbench.Transfers.EnqueueRenameAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false); + return reply; + case "Transfers.EnqueueEmptyRecycleBin": + await _workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false); + return reply; + case "Transfers.EnqueueExtract": + await _workbench.Transfers.EnqueueExtractAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false); + return reply; + case "Transfers.EnqueueCompress": + await _workbench.Transfers.EnqueueCompressAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false); + return reply; + case "Transfers.EnqueueAddToArchive": + await _workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false); + return reply; + case "Transfers.EnqueueVerifyArchive": + await _workbench.Transfers.EnqueueVerifyArchiveAsync(request.S ?? "").ConfigureAwait(false); + return reply; + case "Sources.Refresh": + await _workbench.Sources.RefreshAsync().ConfigureAwait(false); + return reply; + case "Sources.AddUnc": + reply.Source = await _workbench.Sources.AddUncAsync(request.S ?? "").ConfigureAwait(false); + return reply; + case "Sources.EnsureForPath": + reply.Source = await _workbench.Sources.EnsureForPathAsync(request.S ?? "").ConfigureAwait(false); + return reply; + case "Sources.Forget": + reply.Flag = await _workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false); + return reply; + case "Mutations.UpsertSyncProfile": + reply.N = await _workbench.Mutations.UpsertSyncProfileAsync(Read(request.Payload)).ConfigureAwait(false); + return reply; + case "Mutations.DeleteSyncProfile": + await _workbench.Mutations.DeleteSyncProfileAsync(request.N ?? 0).ConfigureAwait(false); + return reply; + case "Mutations.UpsertOperationProfile": + reply.N = await _workbench.Mutations.UpsertOperationProfileAsync(Read(request.Payload)).ConfigureAwait(false); + return reply; + case "Mutations.DeleteOperationProfile": + await _workbench.Mutations.DeleteOperationProfileAsync(request.N ?? 0).ConfigureAwait(false); + return reply; + case "Mutations.CreateRenameBatch": + reply.N = await _workbench.Mutations.CreateRenameBatchAsync(Read(request.Payload) ?? []).ConfigureAwait(false); + return reply; + case "Mutations.MarkRenameBatchUndone": + await _workbench.Mutations.MarkRenameBatchUndoneAsync(request.N ?? 0).ConfigureAwait(false); + return reply; + case "Mutations.EnqueueHashCollisions": + await _workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false); + return reply; + case "Mutations.UpsertRelation": + await _workbench.Mutations.UpsertRelationAsync(Read(request.Payload)).ConfigureAwait(false); + return reply; default: reply.Ok = false; reply.Error = "Unknown op " + request.Op; @@ -217,6 +283,10 @@ public sealed class WorkbenchPipeServer : BackgroundService } } + private static T Read(string? payload) + => JsonSerializer.Deserialize(payload ?? "null", WorkbenchIpc.Json) + ?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name); + private async Task WriteAsync(StreamWriter writer, IpcEnvelope envelope, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(envelope, WorkbenchIpc.Json); diff --git a/src/Explorer.Hosting/WorkbenchHostConnector.cs b/src/Explorer.Hosting/WorkbenchHostConnector.cs new file mode 100644 index 0000000..87a8f2e --- /dev/null +++ b/src/Explorer.Hosting/WorkbenchHostConnector.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using Explorer.Hosting.Ipc; +using Microsoft.Extensions.Logging; + +namespace Explorer.Hosting; + +public static class WorkbenchHostConnector +{ + public static async Task ConnectOrStartAsync( + TimeSpan timeout, + ILogger? logger = null, + CancellationToken cancellationToken = default) + { + var options = new WorkbenchIpcOptions(); + try + { + return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(1), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogDebug(ex, "Background host was not listening yet"); + } + + var exe = HostLogonAutostart.FindHostExecutable(); + if (exe is null) + { + logger?.LogInformation("Explorer.Host.exe is not beside the window; using in-process core"); + return null; + } + + try + { + Process.Start(new ProcessStartInfo + { + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(exe) + }); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Could not start Explorer.Host.exe"); + return null; + } + + try + { + return await WorkbenchPipeClient.ConnectAsync(options, timeout, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe"); + return null; + } + } +} diff --git a/src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs b/src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs index bc7a6a7..5f89d0b 100644 --- a/src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs +++ b/src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs @@ -3,14 +3,14 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Explorer.Analysis; using Explorer.Application; +using Explorer.Contracts; using Explorer.Domain; -using Explorer.Domain.Abstractions; namespace Explorer.Presentation.ViewModels; public sealed partial class DuplicateViewModel : ObservableObject { - private readonly IIndexStore _store; + private readonly IIndexMutations _mutations; private readonly SourceManager _sources; private readonly AnalysisService _analysis; @@ -20,9 +20,9 @@ public sealed partial class DuplicateViewModel : ObservableObject [ObservableProperty] private bool _showIntentional; [ObservableProperty] private bool _showHardlinks; - public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis) + public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis) { - _store = store; + _mutations = mutations; _sources = sources; _analysis = analysis; Groups = []; @@ -50,7 +50,7 @@ public sealed partial class DuplicateViewModel : ObservableObject { var groups = await Task.Run(async () => { - await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false); + await _mutations.EnqueueHashCollisionsAsync(null).ConfigureAwait(false); var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false); var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id); return classified diff --git a/src/Explorer.Presentation/ViewModels/MainViewModel.cs b/src/Explorer.Presentation/ViewModels/MainViewModel.cs index 3631991..a31fa67 100644 --- a/src/Explorer.Presentation/ViewModels/MainViewModel.cs +++ b/src/Explorer.Presentation/ViewModels/MainViewModel.cs @@ -66,7 +66,7 @@ public sealed partial class MainViewModel : ObservableObject SourceManager sources, SearchService search, AnalysisService analysis, - IIndexStore store, + IIndexMutations mutations, IWorkbenchHost workbench, IOsClipboard clipboard, PathHistoryStore pathHistory, @@ -109,7 +109,7 @@ public sealed partial class MainViewModel : ObservableObject Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences); Search = new SearchViewModel(search, sources, volumes); Analysis = new AnalysisViewModel(analysis); - Duplicates = new DuplicateViewModel(store, sources, analysis); + Duplicates = new DuplicateViewModel(mutations, sources, analysis); Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path); Transfers = new TransferQueueViewModel(workbench.Transfers, preferences); Tabs = []; diff --git a/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs b/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs index b92f8eb..6d616fb 100644 --- a/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs +++ b/src/Explorer.Storage.Sqlite/SqliteIndexStore.cs @@ -10,6 +10,7 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable { private readonly string _path; private readonly ILogger _logger; + private readonly bool _readOnly; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly AsyncLocal _writeDepth = new(); private SqliteConnection? _write; @@ -17,10 +18,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable private bool _opened; private int _analysisIndexesReady; - public SqliteIndexStore(string databasePath, ILogger logger) + public SqliteIndexStore(string databasePath, ILogger logger, bool readOnly = false) { _path = databasePath; _logger = logger; + _readOnly = readOnly; Sources = new SourceStore(this); Entries = new EntryStore(this); Excludes = new ExcludeStore(this); @@ -50,6 +52,8 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable public ISyncProfileStore SyncProfiles { get; } public IOperationProfileStore OperationProfiles { get; } + public bool CanWrite => !_readOnly; + internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open."); public async Task OpenAsync(CancellationToken cancellationToken = default) @@ -59,6 +63,12 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable return; } + if (_readOnly) + { + await OpenReadOnlyAsync(cancellationToken).ConfigureAwait(false); + return; + } + _lock = IndexStoreLock.Acquire(_path); try { @@ -84,6 +94,48 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable } } + private async Task OpenReadOnlyAsync(CancellationToken cancellationToken) + { + DapperSetup.Ensure(); + Exception? last = null; + for (var attempt = 0; attempt < 40; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + if (!File.Exists(_path)) + { + throw new InvalidOperationException("Index database is not ready yet."); + } + + _write = new SqliteConnection(BuildConnectionString(_path, readOnly: true)); + await _write.OpenAsync(cancellationToken).ConfigureAwait(false); + ApplyReadPragmas(_write); + if (IndexExists(_write, "ix_entries_dir_agg_all")) + { + Volatile.Write(ref _analysisIndexesReady, 1); + } + + _opened = true; + _logger.LogInformation("Opened index database read-only at {Path}", _path); + return; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + last = ex; + if (_write is not null) + { + await _write.DisposeAsync().ConfigureAwait(false); + _write = null; + } + + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + } + + throw new InvalidOperationException("Could not open the index for read. Is Explorer.Host.exe running?", last); + } + public async Task CloseAsync() { try @@ -114,6 +166,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable public async Task RunWriteAsync(Func work, CancellationToken cancellationToken = default) { + if (_readOnly) + { + throw new InvalidOperationException("Index store is open read-only."); + } + await EnterWriteAsync(cancellationToken).ConfigureAwait(false); var outermost = _writeDepth.Value == 1; SqliteTransaction? tx = null; @@ -165,6 +222,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable throw new InvalidOperationException("Store is not open."); } + if (_readOnly) + { + throw new InvalidOperationException("Index store is open read-only."); + } + await EnterWriteAsync(cancellationToken).ConfigureAwait(false); try { @@ -211,17 +273,17 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable internal async Task OpenReadAsync(CancellationToken cancellationToken) { - var conn = new SqliteConnection(BuildConnectionString(_path)); + var conn = new SqliteConnection(BuildConnectionString(_path, _readOnly)); await conn.OpenAsync(cancellationToken).ConfigureAwait(false); ApplyReadPragmas(conn); return conn; } - internal static string BuildConnectionString(string path) + internal static string BuildConnectionString(string path, bool readOnly = false) => new SqliteConnectionStringBuilder { DataSource = path, - Mode = SqliteOpenMode.ReadWriteCreate, + Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate, Pooling = false }.ToString(); @@ -249,6 +311,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable return Task.CompletedTask; } + if (_readOnly) + { + return Task.CompletedTask; + } + return WriteAsync(conn => { EnsureAnalysisIndexes(conn); diff --git a/tests/Explorer.Application.Tests/WorkbenchHostTests.cs b/tests/Explorer.Application.Tests/WorkbenchHostTests.cs index 8bf7158..199984e 100644 --- a/tests/Explorer.Application.Tests/WorkbenchHostTests.cs +++ b/tests/Explorer.Application.Tests/WorkbenchHostTests.cs @@ -11,9 +11,13 @@ public class WorkbenchHostTests { var indexing = new FakeIndexing(); var transfers = new FakeTransfers(); - IWorkbenchHost host = new WorkbenchHost(indexing, transfers); + var sources = new StubSources(); + var mutations = new StubMutations(); + IWorkbenchHost host = new WorkbenchHost(indexing, transfers, sources, mutations); Assert.Same(indexing, host.Indexing); Assert.Same(transfers, host.Transfers); + Assert.Same(sources, host.Sources); + Assert.Same(mutations, host.Mutations); host.Indexing.EnqueueFullScan(7); host.Transfers.PauseAll(); @@ -57,4 +61,30 @@ public class WorkbenchHostTests public bool MoveUp(long jobId) => false; public bool MoveDown(long jobId) => false; } + + private sealed class StubSources : ISourceHost + { + public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task AddUncAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(new Source { StableKey = "x", DisplayName = path }); + public Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(null); + public Task ForgetAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(false); + } + + private sealed class StubMutations : IIndexMutations + { + public Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default) + => Task.FromResult(0L); + public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default) + => Task.FromResult(0L); + public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default) + => Task.FromResult(0L); + public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask; + } } diff --git a/tests/Explorer.FileOperations.Tests/FolderSyncServiceTests.cs b/tests/Explorer.FileOperations.Tests/FolderSyncServiceTests.cs index deca431..e2ff93e 100644 --- a/tests/Explorer.FileOperations.Tests/FolderSyncServiceTests.cs +++ b/tests/Explorer.FileOperations.Tests/FolderSyncServiceTests.cs @@ -109,12 +109,14 @@ public class FolderSyncServiceTests store, volumes, NullLogger.Instance); + var mutations = new LocalIndexMutations(store); var ops = new FileOperationService(queue, shell, enumerator); var env = new SyncEnv(root); var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); var sync = new FolderSyncService( new FolderSyncPlanner(), store, + mutations, sources, ops, volumes, diff --git a/tests/Explorer.FileOperations.Tests/OperationProfileServiceTests.cs b/tests/Explorer.FileOperations.Tests/OperationProfileServiceTests.cs index 33c4584..0522c82 100644 --- a/tests/Explorer.FileOperations.Tests/OperationProfileServiceTests.cs +++ b/tests/Explorer.FileOperations.Tests/OperationProfileServiceTests.cs @@ -107,10 +107,12 @@ public class OperationProfileServiceTests var ops = new FileOperationService(queue, shell, enumerator); var git = new StubGit(); var planner = new FileOperationProfilePlanner(new RenamePlanner()); - var renames = new RenameBatchService(new RenamePlanner(), store, ops); + var mutations = new LocalIndexMutations(store); + var renames = new RenameBatchService(new RenamePlanner(), store, mutations, ops); var profiles = new OperationProfileService( planner, store, + mutations, ops, renames, volumes, diff --git a/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs b/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs index c818c0f..27a631a 100644 --- a/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs +++ b/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs @@ -316,7 +316,7 @@ public class TransferQueueTests { await using var ctx = await Harness.CreateAsync(); var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum()); - var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, ops); + var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, new LocalIndexMutations(ctx.Store), ops); await ctx.Queue.StartAsync(CancellationToken.None); var subjects = new[] { diff --git a/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs b/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs index 8da3588..d76e042 100644 --- a/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs +++ b/tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs @@ -3,6 +3,7 @@ using Explorer.Domain; using Explorer.Domain.Abstractions; using Explorer.Hosting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Explorer.Hosting.Tests; @@ -23,6 +24,7 @@ public class CoreRegistrationTests Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); Assert.Null(sp.GetService()); } finally @@ -31,6 +33,35 @@ public class CoreRegistrationTests } } + [Fact] + public async Task AddExplorerClient_uses_read_only_store_without_indexing_workers() + { + var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var workbench = new StubWorkbench(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new TempEnv(dir)); + services.AddExplorerClient(workbench); + await using var sp = services.BuildServiceProvider(); + Assert.Same(workbench, sp.GetService()); + Assert.Same(workbench.Indexing, sp.GetService()); + Assert.Same(workbench.Transfers, sp.GetService()); + Assert.Same(workbench.Sources, sp.GetService()); + Assert.Same(workbench.Mutations, sp.GetService()); + Assert.False(sp.GetRequiredService().CanWrite); + var hosted = sp.GetServices().ToList(); + Assert.Contains(hosted, s => s is IndexStoreLifetime); + Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService"); + } + finally + { + try { Directory.Delete(dir, true); } catch { /* ignore */ } + } + } + private sealed class TempEnv : IAppEnvironment { public TempEnv(string dir) @@ -45,4 +76,65 @@ public class CoreRegistrationTests public string DatabasePath { get; } public string LogDirectory { get; } } + + private sealed class StubWorkbench : IWorkbenchHost + { + public IIndexingHost Indexing { get; } = new StubIndexing(); + public ITransferHost Transfers { get; } = new StubTransfers(); + public ISourceHost Sources { get; } = new StubSources(); + public IIndexMutations Mutations { get; } = new StubMutations(); + } + + private sealed class StubIndexing : IIndexingHost + { + public event EventHandler? ProgressChanged = delegate { }; + public void EnqueueFullScan(long sourceId) { } + public void EnqueueFolderScan(long sourceId, string pathRel) { } + public void EnqueueReconcile(long sourceId, string pathRel) { } + public void Cancel(long sourceId) { } + } + + private sealed class StubTransfers : ITransferHost + { + public event EventHandler? Changed = delegate { }; + public event EventHandler? JobFinished = delegate { }; + public bool IsPaused => false; + public IReadOnlyList Snapshot() => []; + public void PauseAll() { } + public void ResumeAll() { } + 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; + } + + private sealed class StubSources : ISourceHost + { + public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task AddUncAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(new Source { StableKey = "x", DisplayName = path }); + public Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(null); + public Task ForgetAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(false); + } + + private sealed class StubMutations : IIndexMutations + { + public Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default) + => Task.FromResult(0L); + public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default) + => Task.FromResult(0L); + public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default) + => Task.FromResult(0L); + public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask; + } } diff --git a/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs b/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs index 147c385..9ab9135 100644 --- a/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs +++ b/tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs @@ -14,7 +14,7 @@ public class WorkbenchPipeTests var indexing = new FakeIndexing(); var transfers = new FakeTransfers(); var server = new WorkbenchPipeServer( - new WorkbenchHost(indexing, transfers), + new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()), new WorkbenchIpcOptions { PipeName = "ew-test" }, NullLogger.Instance); @@ -31,13 +31,23 @@ public class WorkbenchPipeTests var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" }); Assert.True(paused.Paused); + + var copy = server.Handle(new IpcEnvelope + { + V = WorkbenchIpc.ProtocolVersion, + Op = "Transfers.EnqueueCopy", + Paths = ["C:\\a.txt"], + Dest = "D:\\" + }); + Assert.True(copy.Ok); + Assert.Equal(@"D:\", transfers.CopiedDest); } [Fact] public void Handle_rejects_other_protocol_versions() { var server = new WorkbenchPipeServer( - new WorkbenchHost(new FakeIndexing(), new FakeTransfers()), + new WorkbenchHost(new FakeIndexing(), new FakeTransfers(), new StubSources(), new StubMutations()), new WorkbenchIpcOptions(), NullLogger.Instance); var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" }); @@ -72,5 +82,37 @@ public class WorkbenchPipeTests public void ClearFinished() { } public bool MoveUp(long jobId) => false; public bool MoveDown(long jobId) => false; + public string? CopiedDest { get; private set; } + public Task EnqueueCopyAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) + { + CopiedDest = destinationDirectory; + return Task.CompletedTask; + } + } + + private sealed class StubSources : ISourceHost + { + public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task AddUncAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(new Source { StableKey = "x", DisplayName = path }); + public Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(null); + public Task ForgetAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(false); + } + + private sealed class StubMutations : IIndexMutations + { + public Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default) + => Task.FromResult(1L); + public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default) + => Task.FromResult(1L); + public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default) + => Task.FromResult(1L); + public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask; } } diff --git a/tests/Explorer.Storage.Tests/StorageTests.cs b/tests/Explorer.Storage.Tests/StorageTests.cs index d39c2e6..d4957aa 100644 --- a/tests/Explorer.Storage.Tests/StorageTests.cs +++ b/tests/Explorer.Storage.Tests/StorageTests.cs @@ -451,4 +451,24 @@ public class IndexStoreLockTests Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path)); Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path)); } + + [Fact] + public async Task Read_only_store_opens_while_writer_holds_the_mutex() + { + var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db"); + await using var writer = new SqliteIndexStore(path, NullLogger.Instance); + await writer.OpenAsync(); + await writer.AddSourceAsync(@"C:\data"); + + await using var reader = new SqliteIndexStore(path, NullLogger.Instance, readOnly: true); + await reader.OpenAsync(); + Assert.False(reader.CanWrite); + var sources = await reader.Sources.GetAllAsync(); + Assert.Single(sources); + Assert.Equal(@"C:\data", sources[0].LastRootPath); + + var write = await Assert.ThrowsAsync( + () => reader.RunWriteAsync(_ => Task.CompletedTask)); + Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase); + } }