Extract a per-user background host so indexing can later run outside the window.

Keep the GUI as the index writer for now; mutex, named pipe, and opt-in logon autostart prepare Explorer.Host.exe without two SQLite writers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 17:10:49 +02:00
parent 9efb306979
commit 7c23bc2474
40 changed files with 1586 additions and 140 deletions

View File

@@ -1,21 +1,11 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Hosting;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App;
@@ -23,105 +13,20 @@ public static class AppServices
{
public static IServiceCollection AddExplorer(this IServiceCollection services)
{
services.AddSingleton<IClock, SystemClock>();
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddExplorerCore();
services.AddExplorerUi();
return services;
}
public static IServiceCollection AddExplorerUi(this IServiceCollection services)
{
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<ThumbnailService>();
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>());
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
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<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService(sp => sp.GetRequiredService<ThumbnailService>());
services.AddHostedService<WatcherHostedService>();
return services;
}
}
public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}
}

View File

@@ -29,6 +29,10 @@
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Host\Explorer.Host.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
<ProjectReference Include="..\Explorer.Hosting\Explorer.Hosting.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
@@ -39,4 +43,17 @@
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
<Target Name="CopyExplorerHost" AfterTargets="Build">
<PropertyGroup>
<_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))</_HostDir>
</PropertyGroup>
<ItemGroup>
<_HostFiles Include="$(_HostDir)Explorer.Host.exe" />
<_HostFiles Include="$(_HostDir)Explorer.Host.dll" />
<_HostFiles Include="$(_HostDir)Explorer.Host.deps.json" />
<_HostFiles Include="$(_HostDir)Explorer.Host.runtimeconfig.json" />
<_HostFiles Include="$(_HostDir)Explorer.Host.pdb" />
</ItemGroup>
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
</Target>
</Project>

View File

@@ -61,6 +61,16 @@
Content="Include archive contents in the index"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
<CheckBox x:Name="AutoIndexRemovable" Margin="0,0,0,6"
Content="Automatically index removable drives when they appear"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
<TextBlock Text="Background host" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<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."/>
<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,5 +1,6 @@
using System.Windows;
using Explorer.Application;
using Explorer.Hosting;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
@@ -20,6 +21,8 @@ public partial class SettingsWindow : Window
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents;
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
@@ -45,6 +48,8 @@ public partial class SettingsWindow : Window
GroupNetworkPlaces = GroupNetwork.IsChecked == true,
GroupCloudPlaces = GroupCloud.IsChecked == true,
IndexArchiveContents = IndexArchives.IsChecked == true,
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
ShowHiddenFiles = ShowHidden.IsChecked == true,
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
@@ -52,10 +57,41 @@ public partial class SettingsWindow : Window
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim()
};
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
DialogResult = true;
Close();
}
private void ApplyBackgroundHostAutostart(bool enabled)
{
if (enabled)
{
var exe = HostLogonAutostart.FindHostExecutable();
if (exe is null)
{
MessageBox.Show(
this,
"Explorer.Host.exe was not found next to Explorer Workbench, so the sign-in task was not registered.",
"Background host",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
if (!HostLogonAutostart.TryRegister(exe, out var error))
{
MessageBox.Show(this, error, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
}
return;
}
if (!HostLogonAutostart.TryUnregister(out var unregisterError))
{
MessageBox.Show(this, unregisterError, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog

View File

@@ -1,5 +1,10 @@
namespace Explorer.Application;
/// <summary>
/// Detects whether this process already has administrator rights.
/// Elevation is never requested automatically. A future helper may scan USN
/// under elevation; it must not host the transfer queue or index writer.
/// </summary>
public interface IElevatedScanService
{
bool IsElevated { get; }

View File

@@ -0,0 +1,23 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class RemovableAutoIndexPlanner
{
public static IReadOnlyList<long> SourceIdsToScan(IEnumerable<Source> sources, bool autoIndexRemovable)
{
if (!autoIndexRemovable)
{
return [];
}
return sources
.Where(source =>
source.Kind == SourceKind.Removable
&& source.Status == SourceStatus.Online
&& !source.IsIndexed
&& !string.IsNullOrWhiteSpace(source.LastRootPath))
.Select(source => source.Id)
.ToList();
}
}

View File

@@ -24,7 +24,9 @@ public sealed record UiPreferences(
string? OrganizeDocuments = null,
string? OrganizeInstallers = null,
string? OrganizeArchives = null,
string? OrganizeDevelopment = null)
string? OrganizeDevelopment = null,
bool AutoIndexRemovable = false,
bool BackgroundHostAtLogon = false)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -69,6 +71,8 @@ public sealed class UiPreferencesStore
"show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
"auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
.. SevenZipLines(preferences),
.. GitLines(preferences),
.. OrganizeLines(preferences),
@@ -90,6 +94,8 @@ public sealed class UiPreferencesStore
var showHidden = true;
var showProtected = false;
var autoClearQueue = false;
var autoIndexRemovable = false;
var backgroundHostAtLogon = false;
string? sevenZipPath = null;
string? gitPath = null;
string? organizePictures = null;
@@ -149,6 +155,14 @@ public sealed class UiPreferencesStore
{
autoClearQueue = IsTrue(value);
}
else if (key.Equals("auto-index-removable", StringComparison.OrdinalIgnoreCase))
{
autoIndexRemovable = IsTrue(value);
}
else if (key.Equals("background-host-at-logon", StringComparison.OrdinalIgnoreCase))
{
backgroundHostAtLogon = IsTrue(value);
}
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
@@ -215,7 +229,7 @@ public sealed class UiPreferencesStore
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment);
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon);
}
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)

View File

@@ -0,0 +1,15 @@
using Explorer.Contracts;
namespace Explorer.Application;
public sealed class WorkbenchHost : IWorkbenchHost
{
public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers)
{
Indexing = indexing;
Transfers = transfers;
}
public IIndexingHost Indexing { get; }
public ITransferHost Transfers { get; }
}

View File

@@ -0,0 +1,36 @@
using Explorer.Domain;
namespace Explorer.Contracts;
public interface IWorkbenchHost
{
IIndexingHost Indexing { get; }
ITransferHost Transfers { get; }
}
public interface IIndexingHost
{
event EventHandler<ScanProgress>? ProgressChanged;
void EnqueueFullScan(long sourceId);
void EnqueueFolderScan(long sourceId, string pathRel);
void EnqueueReconcile(long sourceId, string pathRel);
void Cancel(long sourceId);
}
public interface ITransferHost
{
event EventHandler? Changed;
event EventHandler<TransferJob>? JobFinished;
bool IsPaused { get; }
IReadOnlyList<TransferJob> Snapshot();
void PauseAll();
void ResumeAll();
void Pause(long jobId);
void Resume(long jobId);
void Retry(long jobId);
void Cancel(long jobId);
void Dismiss(long jobId);
void ClearFinished();
bool MoveUp(long jobId);
bool MoveDown(long jobId);
}

View File

@@ -8,6 +8,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,4 @@
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
@@ -5,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService
public sealed class TransferQueue : BackgroundService, ITransferHost
{
private readonly IOperationExecutor _executor;
private readonly IIndexStore _store;

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Explorer.Host</RootNamespace>
<AssemblyName>Explorer.Host</AssemblyName>
<ApplicationManifest>..\Explorer.App\app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Hosting\Explorer.Hosting.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,37 @@
using Explorer.Hosting;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
var env = new WindowsAppEnvironment();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(env.LogDirectory, "explorer-host-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14)
.CreateLogger();
try
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddExplorerHostProcess();
using var host = builder.Build();
await host.RunAsync().ConfigureAwait(false);
return 0;
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already in use", StringComparison.OrdinalIgnoreCase))
{
Log.Warning(ex, "Index store is already owned; background host exiting");
return 1;
}
catch (Exception ex)
{
Log.Fatal(ex, "Explorer.Host failed");
return 1;
}
finally
{
await Log.CloseAndFlushAsync().ConfigureAwait(false);
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>Explorer.Hosting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Explorer.Hosting.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,100 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Hosting.Ipc;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public static class ExplorerHostServices
{
public static IServiceCollection AddExplorerCore(this IServiceCollection services)
{
services.TryAddSingleton<IClock, SystemClock>();
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
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<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService<WatcherHostedService>();
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 AddExplorerHostProcess(this IServiceCollection services)
{
services.TryAddSingleton<WorkbenchIpcOptions>();
services.AddHostedService<IndexStoreLifetime>();
services.AddExplorerCore();
services.AddHostedService<WorkbenchPipeServer>();
return services;
}
}

View File

@@ -0,0 +1,109 @@
using System.Diagnostics;
namespace Explorer.Hosting;
public static class HostLogonAutostart
{
public const string TaskName = "ExplorerWorkbenchHost";
public static string? FindHostExecutable()
{
var candidate = Path.Combine(AppContext.BaseDirectory, "Explorer.Host.exe");
return File.Exists(candidate) ? candidate : null;
}
public static string[] CreateTaskArgs(string hostExePath)
=>
[
"/Create",
"/TN",
TaskName,
"/TR",
Quote(hostExePath),
"/SC",
"ONLOGON",
"/F",
"/RL",
"LIMITED"
];
public static string[] DeleteTaskArgs() => ["/Delete", "/TN", TaskName, "/F"];
public static bool TryRegister(string hostExePath, out string error)
{
if (!File.Exists(hostExePath))
{
error = "Explorer.Host.exe was not found next to Explorer Workbench.";
return false;
}
return Run(CreateTaskArgs(hostExePath), out error);
}
public static bool TryUnregister(out string error) => Run(DeleteTaskArgs(), out error);
private static string Quote(string path) => "\"" + path + "\"";
private static bool Run(string[] args, out string error)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "schtasks.exe",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
}
};
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
if (!process.WaitForExit(8000))
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
error = "Timed out updating the sign-in task.";
return false;
}
var stderr = process.StandardError.ReadToEnd();
var stdout = process.StandardOutput.ReadToEnd();
if (process.ExitCode != 0)
{
error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
if (IsAlreadyAbsent(args, error))
{
error = "";
return true;
}
if (string.IsNullOrWhiteSpace(error))
{
error = "schtasks exited with code " + process.ExitCode;
}
return false;
}
error = "";
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static bool IsAlreadyAbsent(string[] args, string error)
=> args.Length > 0
&& args[0].Equals("/Delete", StringComparison.OrdinalIgnoreCase)
&& (error.Contains("cannot find", StringComparison.OrdinalIgnoreCase)
|| error.Contains("not found", StringComparison.OrdinalIgnoreCase));
}

View File

@@ -0,0 +1,15 @@
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting;
public sealed class IndexStoreLifetime : IHostedService
{
private readonly IIndexStore _store;
public IndexStoreLifetime(IIndexStore store) => _store = store;
public Task StartAsync(CancellationToken cancellationToken) => _store.OpenAsync(cancellationToken);
public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync();
}

View File

@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Explorer.Domain;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchIpcOptions
{
public string PipeName { get; set; } = WorkbenchIpc.DefaultPipeName;
}
public static class WorkbenchIpc
{
public const int ProtocolVersion = 1;
public static string DefaultPipeName { get; } = Sanitize("ExplorerWorkbench-" + Environment.UserName);
public static JsonSerializerOptions Json { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static string Sanitize(string name)
{
var chars = name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
return new string(chars);
}
}
internal sealed class IpcEnvelope
{
public int V { get; set; } = WorkbenchIpc.ProtocolVersion;
public string? Id { get; set; }
public string? Op { get; set; }
public string? Evt { get; set; }
public bool? Ok { get; set; }
public string? Error { get; set; }
public long? N { get; set; }
public string? S { get; set; }
public bool? Flag { get; set; }
public bool? Paused { get; set; }
public ScanProgress? Progress { get; set; }
public TransferJob? Job { get; set; }
public TransferJob[]? Jobs { get; set; }
}

View File

@@ -0,0 +1,276 @@
using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using Explorer.Contracts;
using Explorer.Domain;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
{
private readonly NamedPipeClientStream _pipe;
private readonly StreamWriter _writer;
private readonly StreamReader _reader;
private readonly SemaphoreSlim _send = new(1, 1);
private readonly ConcurrentDictionary<string, TaskCompletionSource<IpcEnvelope>> _pending = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _readLoop;
private readonly IndexingProxy _indexing;
private readonly TransferProxy _transfers;
private WorkbenchPipeClient(NamedPipeClientStream pipe)
{
_pipe = pipe;
_writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
_indexing = new IndexingProxy(this);
_transfers = new TransferProxy(this);
_readLoop = ReadLoopAsync(_cts.Token);
}
public IIndexingHost Indexing => _indexing;
public ITransferHost Transfers => _transfers;
public static async Task<WorkbenchPipeClient> ConnectAsync(
WorkbenchIpcOptions options,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var deadline = DateTime.UtcNow + timeout;
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
cancellationToken.ThrowIfCancellationRequested();
var pipe = new NamedPipeClientStream(
".",
options.PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
try
{
var remaining = deadline - DateTime.UtcNow;
if (remaining < TimeSpan.FromMilliseconds(50))
{
remaining = TimeSpan.FromMilliseconds(50);
}
await pipe.ConnectAsync(remaining, cancellationToken).ConfigureAwait(false);
var client = new WorkbenchPipeClient(pipe);
await client.CallAsync("Ping", cancellationToken).ConfigureAwait(false);
return client;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
last = ex;
await pipe.DisposeAsync().ConfigureAwait(false);
try
{
await Task.Delay(80, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
}
}
throw new TimeoutException(
$"Could not connect to Explorer Workbench host pipe '{options.PipeName}'.", last);
}
internal IpcEnvelope Call(string op, long? n = null, string? s = null)
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
internal async Task<IpcEnvelope> CallAsync(
string op,
CancellationToken cancellationToken,
long? n = null,
string? s = 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 json = JsonSerializer.Serialize(request, WorkbenchIpc.Json);
await _send.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
catch
{
_pending.TryRemove(id, out _);
throw;
}
finally
{
_send.Release();
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token, _cts.Token);
using var cancelReg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
try
{
var reply = await tcs.Task.ConfigureAwait(false);
if (reply.Ok == false)
{
throw new InvalidOperationException(reply.Error ?? "Host call failed.");
}
return reply;
}
finally
{
_pending.TryRemove(id, out _);
}
}
internal void RaiseProgress(ScanProgress progress) => _indexing.Raise(progress);
internal void RaiseChanged() => _transfers.RaiseChanged();
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
IpcEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<IpcEnvelope>(line, WorkbenchIpc.Json);
}
catch (JsonException)
{
continue;
}
if (envelope is null)
{
continue;
}
if (!string.IsNullOrEmpty(envelope.Evt))
{
switch (envelope.Evt)
{
case "Indexing.Progress" when envelope.Progress is not null:
RaiseProgress(envelope.Progress);
break;
case "Transfers.Changed":
RaiseChanged();
break;
case "Transfers.JobFinished" when envelope.Job is not null:
RaiseFinished(envelope.Job);
break;
}
continue;
}
if (envelope.Id is not null && _pending.TryRemove(envelope.Id, out var tcs))
{
tcs.TrySetResult(envelope);
}
}
}
catch (OperationCanceledException)
{
// shutting down
}
finally
{
foreach (var tcs in _pending.Values)
{
tcs.TrySetCanceled(cancellationToken);
}
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync().ConfigureAwait(false);
try
{
await _writer.DisposeAsync().ConfigureAwait(false);
}
catch
{
// pipe already closed
}
_reader.Dispose();
await _pipe.DisposeAsync().ConfigureAwait(false);
try
{
await _readLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
catch (TimeoutException)
{
// reader may still be unwinding after the pipe close
}
catch (OperationCanceledException)
{
// expected
}
_cts.Dispose();
_send.Dispose();
}
private sealed class IndexingProxy : IIndexingHost
{
private readonly WorkbenchPipeClient _client;
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public IndexingProxy(WorkbenchPipeClient client) => _client = client;
public void EnqueueFullScan(long sourceId) => _client.Call("Indexing.EnqueueFullScan", sourceId);
public void EnqueueFolderScan(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
public void EnqueueReconcile(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel);
public void Cancel(long sourceId) => _client.Call("Indexing.Cancel", sourceId);
public void Raise(ScanProgress progress) => ProgressChanged?.Invoke(this, progress);
}
private sealed class TransferProxy : ITransferHost
{
private readonly WorkbenchPipeClient _client;
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public TransferProxy(WorkbenchPipeClient client) => _client = client;
public bool IsPaused => _client.Call("Transfers.IsPaused").Paused == true;
public IReadOnlyList<TransferJob> Snapshot()
=> _client.Call("Transfers.Snapshot").Jobs ?? [];
public void PauseAll() => _client.Call("Transfers.PauseAll");
public void ResumeAll() => _client.Call("Transfers.ResumeAll");
public void Pause(long jobId) => _client.Call("Transfers.Pause", jobId);
public void Resume(long jobId) => _client.Call("Transfers.Resume", jobId);
public void Retry(long jobId) => _client.Call("Transfers.Retry", jobId);
public void Cancel(long jobId) => _client.Call("Transfers.Cancel", jobId);
public void Dismiss(long jobId) => _client.Call("Transfers.Dismiss", jobId);
public void ClearFinished() => _client.Call("Transfers.ClearFinished");
public bool MoveUp(long jobId) => _client.Call("Transfers.MoveUp", jobId).Flag == true;
public bool MoveDown(long jobId) => _client.Call("Transfers.MoveDown", jobId).Flag == true;
public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
}
}

View File

@@ -0,0 +1,233 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using Explorer.Contracts;
using Explorer.Domain;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeServer : BackgroundService
{
private readonly IWorkbenchHost _workbench;
private readonly WorkbenchIpcOptions _options;
private readonly ILogger<WorkbenchPipeServer> _logger;
private readonly SemaphoreSlim _write = new(1, 1);
private readonly TaskCompletionSource _listening = new(TaskCreationOptions.RunContinuationsAsynchronously);
public WorkbenchPipeServer(
IWorkbenchHost workbench,
WorkbenchIpcOptions options,
ILogger<WorkbenchPipeServer> logger)
{
_workbench = workbench;
_options = options;
_logger = logger;
}
public Task Listening => _listening.Task;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Listening on named pipe {Pipe} protocol v{Version}", _options.PipeName, WorkbenchIpc.ProtocolVersion);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var server = new NamedPipeServerStream(
_options.PipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await using (server.ConfigureAwait(false))
{
_listening.TrySetResult();
using var cancelPipe = stoppingToken.Register(() =>
{
try { server.Dispose(); }
catch (ObjectDisposedException) { }
catch (IOException) { }
});
await server.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
await ServeAsync(server, stoppingToken).ConfigureAwait(false);
}
}
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (IOException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Workbench pipe session ended");
try
{
await Task.Delay(250, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
private async Task ServeAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
{
using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
await using var writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
void OnProgress(object? sender, ScanProgress progress)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Indexing.Progress", Progress = progress }, stoppingToken);
void OnChanged(object? sender, EventArgs e)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.Changed" }, stoppingToken);
void OnFinished(object? sender, TransferJob job)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
_workbench.Indexing.ProgressChanged += OnProgress;
_workbench.Transfers.Changed += OnChanged;
_workbench.Transfers.JobFinished += OnFinished;
try
{
while (!stoppingToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(stoppingToken).ConfigureAwait(false);
if (line is null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
IpcEnvelope request;
try
{
request = JsonSerializer.Deserialize<IpcEnvelope>(line, WorkbenchIpc.Json)
?? new IpcEnvelope { Ok = false, Error = "empty" };
}
catch (JsonException ex)
{
await WriteAsync(writer, new IpcEnvelope { Ok = false, Error = ex.Message }, stoppingToken)
.ConfigureAwait(false);
continue;
}
var response = Handle(request);
await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false);
}
}
finally
{
_workbench.Indexing.ProgressChanged -= OnProgress;
_workbench.Transfers.Changed -= OnChanged;
_workbench.Transfers.JobFinished -= OnFinished;
}
}
internal IpcEnvelope Handle(IpcEnvelope request)
{
var reply = new IpcEnvelope { Id = request.Id, Ok = true };
if (request.V != WorkbenchIpc.ProtocolVersion)
{
reply.Ok = false;
reply.Error = $"Unsupported protocol {request.V}; expected {WorkbenchIpc.ProtocolVersion}.";
return reply;
}
try
{
switch (request.Op)
{
case "Ping":
return reply;
case "Indexing.EnqueueFullScan":
_workbench.Indexing.EnqueueFullScan(request.N ?? 0);
return reply;
case "Indexing.EnqueueFolderScan":
_workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.EnqueueReconcile":
_workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.Cancel":
_workbench.Indexing.Cancel(request.N ?? 0);
return reply;
case "Transfers.Snapshot":
reply.Jobs = _workbench.Transfers.Snapshot().ToArray();
reply.Paused = _workbench.Transfers.IsPaused;
return reply;
case "Transfers.IsPaused":
reply.Paused = _workbench.Transfers.IsPaused;
return reply;
case "Transfers.PauseAll":
_workbench.Transfers.PauseAll();
return reply;
case "Transfers.ResumeAll":
_workbench.Transfers.ResumeAll();
return reply;
case "Transfers.Pause":
_workbench.Transfers.Pause(request.N ?? 0);
return reply;
case "Transfers.Resume":
_workbench.Transfers.Resume(request.N ?? 0);
return reply;
case "Transfers.Retry":
_workbench.Transfers.Retry(request.N ?? 0);
return reply;
case "Transfers.Cancel":
_workbench.Transfers.Cancel(request.N ?? 0);
return reply;
case "Transfers.Dismiss":
_workbench.Transfers.Dismiss(request.N ?? 0);
return reply;
case "Transfers.ClearFinished":
_workbench.Transfers.ClearFinished();
return reply;
case "Transfers.MoveUp":
reply.Flag = _workbench.Transfers.MoveUp(request.N ?? 0);
return reply;
case "Transfers.MoveDown":
reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0);
return reply;
default:
reply.Ok = false;
reply.Error = "Unknown op " + request.Op;
return reply;
}
}
catch (Exception ex)
{
reply.Ok = false;
reply.Error = ex.Message;
return reply;
}
}
private async Task WriteAsync(StreamWriter writer, IpcEnvelope envelope, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(envelope, WorkbenchIpc.Json);
await _write.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_write.Release();
}
}
}

View File

@@ -0,0 +1,65 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting;
public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
private readonly IIndexStore _store;
private readonly IIndexingHost _indexing;
private readonly UiPreferencesStore _preferences;
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles,
IIndexStore store,
IIndexingHost indexing,
UiPreferencesStore preferences)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
_store = store;
_indexing = indexing;
_preferences = preferences;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await TryAutoIndexRemovableAsync(stoppingToken).ConfigureAwait(false);
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}
private async Task TryAutoIndexRemovableAsync(CancellationToken cancellationToken)
{
var prefs = _preferences.Load();
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var sourceId in RemovableAutoIndexPlanner.SourceIdsToScan(sources, prefs.AutoIndexRemovable))
{
_indexing.EnqueueFullScan(sourceId);
}
}
}

View File

@@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using System.Threading.Channels;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
@@ -6,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class IndexingCoordinator : BackgroundService
public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
{
private readonly IIndexStore _store;
private readonly FilesystemScanner _scanner;

View File

@@ -10,9 +10,9 @@
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
</ItemGroup>

View File

@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
@@ -12,7 +12,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly IGitStatusProvider _git;
private readonly IThumbnailService? _thumbnails;
@@ -43,7 +43,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
IIndexingHost indexing,
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)

View File

@@ -1,8 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
@@ -22,7 +22,7 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
public ExplorerTabViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
IIndexingHost indexing,
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)

View File

@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Search;
using Explorer.Analysis;
using Explorer.Domain.Abstractions;
@@ -16,7 +16,7 @@ public sealed partial class MainViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
@@ -63,12 +63,11 @@ public sealed partial class MainViewModel : ObservableObject
public MainViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources,
SearchService search,
AnalysisService analysis,
IIndexStore store,
TransferQueue transfers,
IWorkbenchHost workbench,
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
@@ -88,7 +87,7 @@ public sealed partial class MainViewModel : ObservableObject
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_indexing = workbench.Indexing;
_sources = sources;
_pathHistory = pathHistory;
_providers = providers;
@@ -112,10 +111,10 @@ public sealed partial class MainViewModel : ObservableObject
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
Transfers = new TransferQueueViewModel(transfers, preferences);
Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
Tabs = [];
Clipboard = clipboard;
transfers.JobFinished += (_, job) =>
workbench.Transfers.JobFinished += (_, job) =>
{
void Go() => _ = OnTransferFinishedAsync(job);
if (_ui is { } ctx)

View File

@@ -2,8 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
@@ -186,7 +186,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
public sealed partial class TransferQueueViewModel : ObservableObject
{
private readonly TransferQueue _queue;
private readonly ITransferHost _queue;
private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = [];
@@ -205,7 +205,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
[ObservableProperty] private double _overallProgress;
[ObservableProperty] private bool _hasOverallProgress;
public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences)
public TransferQueueViewModel(ITransferHost queue, UiPreferencesStore preferences)
{
_queue = queue;
_preferences = preferences;

View File

@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace Explorer.Storage.Sqlite;
/// <summary>
/// Per-user lock so only one process (and one in-process owner) can open the index for write.
/// GUI and Explorer.Host.exe must not share a writer. Named mutex recovers from a crashed owner;
/// an in-process table stops the same thread re-entering the mutex.
/// </summary>
public sealed class IndexStoreLock : IDisposable
{
private static readonly ConcurrentDictionary<string, byte> InProcessOwners = new(StringComparer.Ordinal);
private readonly Mutex _mutex;
private readonly string _name;
private bool _owned;
private IndexStoreLock(Mutex mutex, string name)
{
_mutex = mutex;
_name = name;
_owned = true;
}
public static string MutexNameFor(string databasePath)
{
var full = Path.GetFullPath(databasePath);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(full.ToUpperInvariant())))[..16];
return @"Local\ExplorerWorkbench-Index-" + hash;
}
public static IndexStoreLock Acquire(string databasePath, TimeSpan? timeout = null)
{
var name = MutexNameFor(databasePath);
if (!InProcessOwners.TryAdd(name, 0))
{
throw new InvalidOperationException(
"The Explorer Workbench index is already in use by another process.");
}
var mutex = new Mutex(false, name);
var wait = timeout ?? TimeSpan.Zero;
try
{
if (!mutex.WaitOne(wait))
{
mutex.Dispose();
InProcessOwners.TryRemove(name, out _);
throw new InvalidOperationException(
"The Explorer Workbench index is already in use by another process.");
}
}
catch (AbandonedMutexException)
{
// Previous owner crashed; this process now owns the mutex.
}
catch
{
mutex.Dispose();
InProcessOwners.TryRemove(name, out _);
throw;
}
return new IndexStoreLock(mutex, name);
}
public void Dispose()
{
if (_owned)
{
try
{
_mutex.ReleaseMutex();
}
catch (ApplicationException)
{
// Already released or not owned.
}
_owned = false;
InProcessOwners.TryRemove(_name, out _);
}
_mutex.Dispose();
}
}

View File

@@ -13,6 +13,7 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal<int> _writeDepth = new();
private SqliteConnection? _write;
private IndexStoreLock? _lock;
private bool _opened;
private int _analysisIndexesReady;
@@ -58,31 +59,48 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return;
}
DapperSetup.Ensure();
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
_write = new SqliteConnection(BuildConnectionString(_path));
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyPragmas(_write);
Migrate(_write);
if (IndexExists(_write, "ix_entries_dir_agg_all"))
_lock = IndexStoreLock.Acquire(_path);
try
{
Volatile.Write(ref _analysisIndexesReady, 1);
}
DapperSetup.Ensure();
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
_write = new SqliteConnection(BuildConnectionString(_path));
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyPragmas(_write);
Migrate(_write);
if (IndexExists(_write, "ix_entries_dir_agg_all"))
{
Volatile.Write(ref _analysisIndexesReady, 1);
}
_opened = true;
_logger.LogInformation("Opened index database at {Path}", _path);
_opened = true;
_logger.LogInformation("Opened index database at {Path}", _path);
}
catch
{
_lock.Dispose();
_lock = null;
throw;
}
}
public async Task CloseAsync()
{
if (_write is not null)
try
{
await _write.CloseAsync().ConfigureAwait(false);
await _write.DisposeAsync().ConfigureAwait(false);
_write = null;
if (_write is not null)
{
await _write.CloseAsync().ConfigureAwait(false);
await _write.DisposeAsync().ConfigureAwait(false);
_write = null;
}
}
finally
{
_opened = false;
_lock?.Dispose();
_lock = null;
}
_opened = false;
}
public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default)

View File

@@ -21,6 +21,7 @@ public sealed class WindowsElevatedScanService : IElevatedScanService
}
}
// Queue, index writer, and watchers stay in the user session. Elevation is never requested.
public bool CanRequestElevation => false;
public string ProtectedContentHint => "Protected content requires administrator privileges.";