Cut over the window to Explorer.Host.exe so only the host writes the index.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 17:39:13 +02:00
parent 7c23bc2474
commit 48d03f794f
26 changed files with 774 additions and 59 deletions

View File

@@ -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<MainViewModel>();
@@ -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);
}

View File

@@ -70,7 +70,7 @@
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
Content="Start Explorer.Host.exe at Windows sign-in"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="Registers a per-user logon task. The window still owns indexing in this version. If the host starts while this window is open, it exits because the index is already in use."/>
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."/>
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
@@ -12,6 +13,7 @@ public sealed class SourceManager
private readonly IAppEnvironment _env;
private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger;
private readonly ISourceHost? _remote;
private readonly object _refreshLock = new();
private Task<IReadOnlyList<Source>>? _refreshInFlight;
@@ -23,18 +25,31 @@ public sealed class SourceManager
IVolumeService volumes,
IAppEnvironment env,
IClock clock,
ILogger<SourceManager> logger)
ILogger<SourceManager> 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<IReadOnlyList<Source>> 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<Source> 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<bool> 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)
{

View File

@@ -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<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> _sources.AddUncAsync(path, cancellationToken);
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> _sources.EnsureForPathAsync(path, cancellationToken);
public Task<bool> 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<long> 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<long> 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<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> 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);
}

View File

@@ -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<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueDeleteAsync(IReadOnlyList<string> 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<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
public interface ISourceHost
{
Task RefreshAsync(CancellationToken cancellationToken = default);
Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default);
Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default);
}
public interface IIndexMutations
{
Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default);
Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default);
Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default);
Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> 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);
}

View File

@@ -20,6 +20,8 @@ public interface IIndexStore
ISyncProfileStore SyncProfiles { get; }
IOperationProfileStore OperationProfiles { get; }
bool CanWrite { get; }
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -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<OperationPlan> 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,

View File

@@ -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<OperationProfile> 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,

View File

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

View File

@@ -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<IClock, SystemClock>();
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
@@ -34,7 +42,7 @@ public static class ExplorerHostServices
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger);
return new SqliteIndexStore(env.DatabasePath, logger, readOnly);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
@@ -47,7 +55,13 @@ public static class ExplorerHostServices
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton(sp => new SourceManager(
sp.GetRequiredService<IIndexStore>(),
sp.GetRequiredService<IVolumeService>(),
sp.GetRequiredService<IAppEnvironment>(),
sp.GetRequiredService<IClock>(),
sp.GetRequiredService<ILogger<SourceManager>>(),
sp.GetService<ISourceHost>()));
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
@@ -57,24 +71,26 @@ public static class ExplorerHostServices
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<RenamePlanner>();
return services;
}
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
{
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<ITransferHost>(sp => sp.GetRequiredService<TransferQueue>());
services.AddSingleton<IIndexingHost>(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddSingleton<IWorkbenchHost, WorkbenchHost>();
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenamePlanner>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
services.AddSingleton<IIndexMutations>(sp => new LocalIndexMutations(sp.GetRequiredService<IIndexStore>()));
services.AddSingleton<IWorkbenchHost>(sp => new WorkbenchHost(
sp.GetRequiredService<IIndexingHost>(),
sp.GetRequiredService<ITransferHost>(),
new LocalSourceHost(sp.GetRequiredService<SourceManager>()),
sp.GetRequiredService<IIndexMutations>()));
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
@@ -85,10 +101,32 @@ public static class ExplorerHostServices
return services;
}
/// <summary>
/// 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.
/// </summary>
public static IServiceCollection AddExplorerOperations(this IServiceCollection services)
{
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
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<IndexStoreLifetime>();
return services;
}
public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
{
services.TryAddSingleton<WorkbenchIpcOptions>();

View File

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

View File

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

View File

@@ -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<WorkbenchPipeClient> 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<IpcEnvelope>(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<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueDeleteAsync(IReadOnlyList<string> 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<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueCompress", cancellationToken, dest: archivePath, paths: sources.ToArray());
public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> 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<Source> 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<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
private async Task<Source?> 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<long> 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<long> 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<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> 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>(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json);
}
}

View File

@@ -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<IpcEnvelope> 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<SyncProfile>(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<OperationProfile>(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<RenameBatchItem[]>(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<FileRelation>(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<T>(string? payload)
=> JsonSerializer.Deserialize<T>(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);

View File

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

View File

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

View File

@@ -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 = [];

View File

@@ -10,6 +10,7 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
{
private readonly string _path;
private readonly ILogger<SqliteIndexStore> _logger;
private readonly bool _readOnly;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal<int> _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<SqliteIndexStore> logger)
public SqliteIndexStore(string databasePath, ILogger<SqliteIndexStore> 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<IIndexStore, Task> 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<SqliteConnection> 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);

View File

@@ -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<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> 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;
}
}

View File

@@ -109,12 +109,14 @@ public class FolderSyncServiceTests
store,
volumes,
NullLogger<TransferQueue>.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<SourceManager>.Instance);
var sync = new FolderSyncService(
new FolderSyncPlanner(),
store,
mutations,
sources,
ops,
volumes,

View File

@@ -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,

View File

@@ -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[]
{

View File

@@ -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<IWorkbenchHost>());
Assert.NotNull(sp.GetService<IIndexingHost>());
Assert.NotNull(sp.GetService<ITransferHost>());
Assert.NotNull(sp.GetService<IIndexMutations>());
Assert.Null(sp.GetService<IOsClipboard>());
}
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<IAppEnvironment>(new TempEnv(dir));
services.AddExplorerClient(workbench);
await using var sp = services.BuildServiceProvider();
Assert.Same(workbench, sp.GetService<IWorkbenchHost>());
Assert.Same(workbench.Indexing, sp.GetService<IIndexingHost>());
Assert.Same(workbench.Transfers, sp.GetService<ITransferHost>());
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
var hosted = sp.GetServices<IHostedService>().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<ScanProgress>? 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<TransferJob>? JobFinished = delegate { };
public bool IsPaused => false;
public IReadOnlyList<TransferJob> 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<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> 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;
}
}

View File

@@ -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<WorkbenchPipeServer>.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<WorkbenchPipeServer>.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<string> 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<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> 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;
}
}

View File

@@ -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<SqliteIndexStore>.Instance);
await writer.OpenAsync();
await writer.AddSourceAsync(@"C:\data");
await using var reader = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.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<InvalidOperationException>(
() => reader.RunWriteAsync(_ => Task.CompletedTask));
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
}
}