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

@@ -6,6 +6,8 @@
<Project Path="src/Explorer.Contracts/Explorer.Contracts.csproj" /> <Project Path="src/Explorer.Contracts/Explorer.Contracts.csproj" />
<Project Path="src/Explorer.Domain/Explorer.Domain.csproj" /> <Project Path="src/Explorer.Domain/Explorer.Domain.csproj" />
<Project Path="src/Explorer.FileOperations/Explorer.FileOperations.csproj" /> <Project Path="src/Explorer.FileOperations/Explorer.FileOperations.csproj" />
<Project Path="src/Explorer.Host/Explorer.Host.csproj" />
<Project Path="src/Explorer.Hosting/Explorer.Hosting.csproj" />
<Project Path="src/Explorer.Indexing/Explorer.Indexing.csproj" /> <Project Path="src/Explorer.Indexing/Explorer.Indexing.csproj" />
<Project Path="src/Explorer.Plugin.Abstractions/Explorer.Plugin.Abstractions.csproj" /> <Project Path="src/Explorer.Plugin.Abstractions/Explorer.Plugin.Abstractions.csproj" />
<Project Path="src/Explorer.Plugin.GoogleDrive/Explorer.Plugin.GoogleDrive.csproj" /> <Project Path="src/Explorer.Plugin.GoogleDrive/Explorer.Plugin.GoogleDrive.csproj" />
@@ -21,6 +23,7 @@
<Project Path="tests/Explorer.Application.Tests/Explorer.Application.Tests.csproj" /> <Project Path="tests/Explorer.Application.Tests/Explorer.Application.Tests.csproj" />
<Project Path="tests/Explorer.Domain.Tests/Explorer.Domain.Tests.csproj" /> <Project Path="tests/Explorer.Domain.Tests/Explorer.Domain.Tests.csproj" />
<Project Path="tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj" /> <Project Path="tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj" />
<Project Path="tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj" />
<Project Path="tests/Explorer.Indexing.Tests/Explorer.Indexing.Tests.csproj" /> <Project Path="tests/Explorer.Indexing.Tests/Explorer.Indexing.Tests.csproj" />
<Project Path="tests/Explorer.Search.Tests/Explorer.Search.Tests.csproj" /> <Project Path="tests/Explorer.Search.Tests/Explorer.Search.Tests.csproj" />
<Project Path="tests/Explorer.Storage.Tests/Explorer.Storage.Tests.csproj" /> <Project Path="tests/Explorer.Storage.Tests/Explorer.Storage.Tests.csproj" />

View File

@@ -1,21 +1,11 @@
using Explorer.Analysis;
using Explorer.Application; using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Explorer.FileOperations; using Explorer.Hosting;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation; using Explorer.Presentation;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows; using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App; namespace Explorer.App;
@@ -23,105 +13,20 @@ public static class AppServices
{ {
public static IServiceCollection AddExplorer(this IServiceCollection services) public static IServiceCollection AddExplorer(this IServiceCollection services)
{ {
services.AddSingleton<IClock, SystemClock>(); services.AddExplorerCore();
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>(); services.AddExplorerUi();
services.AddSingleton<IVolumeService, WindowsVolumeService>(); return services;
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>(); }
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>(); public static IServiceCollection AddExplorerUi(this IServiceCollection services)
{
services.AddSingleton<IOsClipboard, Services.WpfClipboard>(); 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<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<ThumbnailService>();
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<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<MainViewModel>();
services.AddSingleton<MainWindow>(); 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(sp => sp.GetRequiredService<ThumbnailService>());
services.AddHostedService<WatcherHostedService>();
return services; 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.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" /> <ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.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.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" /> <ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.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.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" /> <ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup> </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> </Project>

View File

@@ -61,6 +61,16 @@
Content="Include archive contents in the index"/> Content="Include archive contents in the index"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12" <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."/> 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 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" <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"

View File

@@ -1,5 +1,6 @@
using System.Windows; using System.Windows;
using Explorer.Application; using Explorer.Application;
using Explorer.Hosting;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
namespace Explorer.App; namespace Explorer.App;
@@ -20,6 +21,8 @@ public partial class SettingsWindow : Window
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces; GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces; GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents; IndexArchives.IsChecked = prefs.IndexArchiveContents;
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
ShowHidden.IsChecked = prefs.ShowHiddenFiles; ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations; ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone; AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
@@ -45,6 +48,8 @@ public partial class SettingsWindow : Window
GroupNetworkPlaces = GroupNetwork.IsChecked == true, GroupNetworkPlaces = GroupNetwork.IsChecked == true,
GroupCloudPlaces = GroupCloud.IsChecked == true, GroupCloudPlaces = GroupCloud.IsChecked == true,
IndexArchiveContents = IndexArchives.IsChecked == true, IndexArchiveContents = IndexArchives.IsChecked == true,
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
ShowHiddenFiles = ShowHidden.IsChecked == true, ShowHiddenFiles = ShowHidden.IsChecked == true,
ShowProtectedSystemLocations = ShowProtected.IsChecked == true, ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true, AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
@@ -52,10 +57,41 @@ public partial class SettingsWindow : Window
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim() GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim()
}; };
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true); await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
DialogResult = true; DialogResult = true;
Close(); 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) private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{ {
var dlg = new Microsoft.Win32.OpenFileDialog var dlg = new Microsoft.Win32.OpenFileDialog

View File

@@ -1,5 +1,10 @@
namespace Explorer.Application; 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 public interface IElevatedScanService
{ {
bool IsElevated { get; } 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? OrganizeDocuments = null,
string? OrganizeInstallers = null, string? OrganizeInstallers = null,
string? OrganizeArchives = 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); 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-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"), "show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "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), .. SevenZipLines(preferences),
.. GitLines(preferences), .. GitLines(preferences),
.. OrganizeLines(preferences), .. OrganizeLines(preferences),
@@ -90,6 +94,8 @@ public sealed class UiPreferencesStore
var showHidden = true; var showHidden = true;
var showProtected = false; var showProtected = false;
var autoClearQueue = false; var autoClearQueue = false;
var autoIndexRemovable = false;
var backgroundHostAtLogon = false;
string? sevenZipPath = null; string? sevenZipPath = null;
string? gitPath = null; string? gitPath = null;
string? organizePictures = null; string? organizePictures = null;
@@ -149,6 +155,14 @@ public sealed class UiPreferencesStore
{ {
autoClearQueue = IsTrue(value); 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)) else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{ {
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value; sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
@@ -215,7 +229,7 @@ public sealed class UiPreferencesStore
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue, theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives, organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment); organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon);
} }
private static IEnumerable<string> SevenZipLines(UiPreferences preferences) 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>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.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.Domain\Explorer.Domain.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,3 +1,4 @@
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -5,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.FileOperations; namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService public sealed class TransferQueue : BackgroundService, ITransferHost
{ {
private readonly IOperationExecutor _executor; private readonly IOperationExecutor _executor;
private readonly IIndexStore _store; 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>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.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.Domain\Explorer.Domain.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@@ -10,9 +10,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" /> <ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.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.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.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.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" /> <ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
</ItemGroup> </ItemGroup>

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels; namespace Explorer.Presentation.ViewModels;
@@ -186,7 +186,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
public sealed partial class TransferQueueViewModel : ObservableObject public sealed partial class TransferQueueViewModel : ObservableObject
{ {
private readonly TransferQueue _queue; private readonly ITransferHost _queue;
private readonly UiPreferencesStore _preferences; private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current; private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = []; 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 double _overallProgress;
[ObservableProperty] private bool _hasOverallProgress; [ObservableProperty] private bool _hasOverallProgress;
public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences) public TransferQueueViewModel(ITransferHost queue, UiPreferencesStore preferences)
{ {
_queue = queue; _queue = queue;
_preferences = preferences; _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 SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal<int> _writeDepth = new(); private readonly AsyncLocal<int> _writeDepth = new();
private SqliteConnection? _write; private SqliteConnection? _write;
private IndexStoreLock? _lock;
private bool _opened; private bool _opened;
private int _analysisIndexesReady; private int _analysisIndexesReady;
@@ -58,31 +59,48 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return; return;
} }
DapperSetup.Ensure(); _lock = IndexStoreLock.Acquire(_path);
Directory.CreateDirectory(Path.GetDirectoryName(_path)!); try
_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); 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; _opened = true;
_logger.LogInformation("Opened index database at {Path}", _path); _logger.LogInformation("Opened index database at {Path}", _path);
}
catch
{
_lock.Dispose();
_lock = null;
throw;
}
} }
public async Task CloseAsync() public async Task CloseAsync()
{ {
if (_write is not null) try
{ {
await _write.CloseAsync().ConfigureAwait(false); if (_write is not null)
await _write.DisposeAsync().ConfigureAwait(false); {
_write = 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) 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 bool CanRequestElevation => false;
public string ProtectedContentHint => "Protected content requires administrator privileges."; public string ProtectedContentHint => "Protected content requires administrator privileges.";

View File

@@ -0,0 +1,73 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Windows;
namespace Explorer.Application.Tests;
public class RemovableAutoIndexPlannerTests
{
[Fact]
public void Does_nothing_when_disabled()
{
var sources = new[] { Removable(1, indexed: false) };
Assert.Empty(RemovableAutoIndexPlanner.SourceIdsToScan(sources, autoIndexRemovable: false));
}
[Fact]
public void Queues_online_unindexed_removable_only()
{
var sources = new[]
{
Removable(1, indexed: false),
Removable(2, indexed: true),
Removable(3, indexed: false, status: SourceStatus.Offline),
Cloud(4),
Local(5)
};
Assert.Equal(new[] { 1L }, RemovableAutoIndexPlanner.SourceIdsToScan(sources, autoIndexRemovable: true));
}
private static Source Removable(long id, bool indexed, SourceStatus status = SourceStatus.Online)
=> new()
{
Id = id,
StableKey = id.ToString(),
Kind = SourceKind.Removable,
DisplayName = "USB",
LastRootPath = @"E:\",
Status = status,
LastIndexedUtc = indexed ? DateTimeOffset.UtcNow : null
};
private static Source Cloud(long id)
=> new()
{
Id = id,
StableKey = "cloud",
Kind = SourceKind.Cloud,
DisplayName = "Cloud",
LastRootPath = @"C:\Users\me\OneDrive",
Status = SourceStatus.Online
};
private static Source Local(long id)
=> new()
{
Id = id,
StableKey = "local",
Kind = SourceKind.NtfsLocal,
DisplayName = "Local",
LastRootPath = @"C:\",
Status = SourceStatus.Online
};
}
public class ElevatedScanServiceTests
{
[Fact]
public void Never_requests_elevation()
{
IElevatedScanService service = new WindowsElevatedScanService();
Assert.False(service.CanRequestElevation);
}
}

View File

@@ -43,6 +43,18 @@ public class UiPreferencesStoreTests
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath); Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
} }
[Fact]
public void Parse_reads_host_and_removable_index_flags()
{
var prefs = UiPreferencesStore.Parse(
[
"auto-index-removable=true",
"background-host-at-logon=true"
]);
Assert.True(prefs.AutoIndexRemovable);
Assert.True(prefs.BackgroundHostAtLogon);
}
[Fact] [Fact]
public void Parse_defaults_missing_keys() public void Parse_defaults_missing_keys()
{ {
@@ -54,6 +66,8 @@ public class UiPreferencesStoreTests
Assert.True(prefs.ShowHiddenFiles); Assert.True(prefs.ShowHiddenFiles);
Assert.False(prefs.ShowProtectedSystemLocations); Assert.False(prefs.ShowProtectedSystemLocations);
Assert.False(prefs.AutoClearQueueWhenDone); Assert.False(prefs.AutoClearQueueWhenDone);
Assert.False(prefs.AutoIndexRemovable);
Assert.False(prefs.BackgroundHostAtLogon);
Assert.Null(prefs.SevenZipPath); Assert.Null(prefs.SevenZipPath);
Assert.Null(prefs.GitPath); Assert.Null(prefs.GitPath);
} }
@@ -95,6 +109,8 @@ public class UiPreferencesStoreTests
Assert.True(loaded.ShowHiddenFiles); Assert.True(loaded.ShowHiddenFiles);
Assert.False(loaded.ShowProtectedSystemLocations); Assert.False(loaded.ShowProtectedSystemLocations);
Assert.True(loaded.AutoClearQueueWhenDone); Assert.True(loaded.AutoClearQueueWhenDone);
Assert.False(loaded.AutoIndexRemovable);
Assert.False(loaded.BackgroundHostAtLogon);
Assert.Equal(1100, loaded.WindowWidth); Assert.Equal(1100, loaded.WindowWidth);
Assert.Equal(720, loaded.WindowHeight); Assert.Equal(720, loaded.WindowHeight);
Assert.Equal(300, loaded.TreeWidth); Assert.Equal(300, loaded.TreeWidth);

View File

@@ -0,0 +1,60 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class WorkbenchHostTests
{
[Fact]
public void Forwards_indexing_and_transfer_sessions()
{
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
IWorkbenchHost host = new WorkbenchHost(indexing, transfers);
Assert.Same(indexing, host.Indexing);
Assert.Same(transfers, host.Transfers);
host.Indexing.EnqueueFullScan(7);
host.Transfers.PauseAll();
Assert.Equal(7, indexing.FullScanId);
Assert.True(transfers.PausedAll);
}
private sealed class FakeIndexing : IIndexingHost
{
public long FullScanId { get; private set; }
public event EventHandler<ScanProgress>? ProgressChanged;
public void EnqueueFullScan(long sourceId) => FullScanId = sourceId;
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
public void Raise() => ProgressChanged?.Invoke(this, new ScanProgress
{
SourceId = 1,
CurrentPath = "",
Status = ScanJobStatus.Running
});
}
private sealed class FakeTransfers : ITransferHost
{
public bool PausedAll { get; private set; }
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => PausedAll;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() => PausedAll = true;
public void ResumeAll() => PausedAll = false;
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
}

View File

@@ -0,0 +1,48 @@
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace Explorer.Hosting.Tests;
public class CoreRegistrationTests
{
[Fact]
public async Task AddExplorerCore_registers_workbench_without_clipboard()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IAppEnvironment>(new TempEnv(dir));
services.AddExplorerCore();
await using var sp = services.BuildServiceProvider();
Assert.NotNull(sp.GetService<IWorkbenchHost>());
Assert.NotNull(sp.GetService<IIndexingHost>());
Assert.NotNull(sp.GetService<ITransferHost>());
Assert.Null(sp.GetService<IOsClipboard>());
}
finally
{
try { Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
private sealed class TempEnv : IAppEnvironment
{
public TempEnv(string dir)
{
DataDirectory = dir;
DatabasePath = Path.Combine(dir, "index.db");
LogDirectory = Path.Combine(dir, "logs");
Directory.CreateDirectory(LogDirectory);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</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" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\..\src\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\..\src\Explorer.Hosting\Explorer.Hosting.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Explorer.Hosting;
namespace Explorer.Hosting.Tests;
public class HostLogonAutostartTests
{
[Fact]
public void Create_args_are_per_user_logon_not_system_service()
{
var args = HostLogonAutostart.CreateTaskArgs(@"C:\Tools\Explorer.Host.exe");
Assert.Contains("/SC", args);
Assert.Contains("ONLOGON", args);
Assert.Contains("/RL", args);
Assert.Contains("LIMITED", args);
Assert.Contains(HostLogonAutostart.TaskName, args);
Assert.DoesNotContain("ONSTART", args);
Assert.DoesNotContain("/RU", args);
}
}

View File

@@ -0,0 +1,76 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Hosting.Ipc;
using Microsoft.Extensions.Logging.Abstractions;
namespace Explorer.Hosting.Tests;
public class WorkbenchPipeTests
{
[Fact]
public void Handle_forwards_indexing_and_transfer_calls()
{
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
var server = new WorkbenchPipeServer(
new WorkbenchHost(indexing, transfers),
new WorkbenchIpcOptions { PipeName = "ew-test" },
NullLogger<WorkbenchPipeServer>.Instance);
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
Assert.True(ping.Ok);
var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 });
Assert.True(scan.Ok);
Assert.Equal(42, indexing.FullScanId);
var pause = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.PauseAll" });
Assert.True(pause.Ok);
Assert.True(transfers.PausedAll);
var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" });
Assert.True(paused.Paused);
}
[Fact]
public void Handle_rejects_other_protocol_versions()
{
var server = new WorkbenchPipeServer(
new WorkbenchHost(new FakeIndexing(), new FakeTransfers()),
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
Assert.False(reply.Ok);
Assert.Contains("99", reply.Error);
}
private sealed class FakeIndexing : IIndexingHost
{
public long FullScanId { get; private set; }
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public void EnqueueFullScan(long sourceId) => FullScanId = sourceId;
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
}
private sealed class FakeTransfers : ITransferHost
{
public bool PausedAll { get; private set; }
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => PausedAll;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() => PausedAll = true;
public void ResumeAll() => PausedAll = false;
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
}

View File

@@ -427,3 +427,28 @@ public class FileRelationTests
Assert.Empty(await store.OperationProfiles.ListAsync()); Assert.Empty(await store.OperationProfiles.ListAsync());
} }
} }
public class IndexStoreLockTests
{
[Fact]
public async Task Second_writer_on_same_path_is_rejected()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
await using var first = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await first.OpenAsync();
var second = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => second.OpenAsync());
Assert.Contains("already in use", ex.Message, StringComparison.OrdinalIgnoreCase);
await first.DisposeAsync();
await second.OpenAsync();
await second.DisposeAsync();
}
[Fact]
public void Mutex_name_is_stable_for_the_same_path()
{
var path = @"C:\Users\me\AppData\Local\ExplorerWorkbench\index.db";
Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path));
Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path));
}
}