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:
@@ -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" />
|
||||||
|
|||||||
@@ -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<IIndexStore>(sp =>
|
|
||||||
{
|
{
|
||||||
var env = sp.GetRequiredService<IAppEnvironment>();
|
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 archive’s 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 archive’s 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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
23
src/Explorer.Application/RemovableAutoIndexPlanner.cs
Normal file
23
src/Explorer.Application/RemovableAutoIndexPlanner.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
15
src/Explorer.Application/WorkbenchHost.cs
Normal file
15
src/Explorer.Application/WorkbenchHost.cs
Normal 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; }
|
||||||
|
}
|
||||||
36
src/Explorer.Contracts/IWorkbenchHost.cs
Normal file
36
src/Explorer.Contracts/IWorkbenchHost.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
21
src/Explorer.Host/Explorer.Host.csproj
Normal file
21
src/Explorer.Host/Explorer.Host.csproj
Normal 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>
|
||||||
37
src/Explorer.Host/Program.cs
Normal file
37
src/Explorer.Host/Program.cs
Normal 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);
|
||||||
|
}
|
||||||
29
src/Explorer.Hosting/Explorer.Hosting.csproj
Normal file
29
src/Explorer.Hosting/Explorer.Hosting.csproj
Normal 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>
|
||||||
100
src/Explorer.Hosting/ExplorerHostServices.cs
Normal file
100
src/Explorer.Hosting/ExplorerHostServices.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/Explorer.Hosting/HostLogonAutostart.cs
Normal file
109
src/Explorer.Hosting/HostLogonAutostart.cs
Normal 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));
|
||||||
|
}
|
||||||
15
src/Explorer.Hosting/IndexStoreLifetime.cs
Normal file
15
src/Explorer.Hosting/IndexStoreLifetime.cs
Normal 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();
|
||||||
|
}
|
||||||
48
src/Explorer.Hosting/Ipc/WorkbenchIpc.cs
Normal file
48
src/Explorer.Hosting/Ipc/WorkbenchIpc.cs
Normal 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; }
|
||||||
|
}
|
||||||
276
src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs
Normal file
276
src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
Normal file
233
src/Explorer.Hosting/Ipc/WorkbenchPipeServer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/Explorer.Hosting/WatcherHostedService.cs
Normal file
65
src/Explorer.Hosting/WatcherHostedService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
88
src/Explorer.Storage.Sqlite/IndexStoreLock.cs
Normal file
88
src/Explorer.Storage.Sqlite/IndexStoreLock.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +59,9 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_lock = IndexStoreLock.Acquire(_path);
|
||||||
|
try
|
||||||
|
{
|
||||||
DapperSetup.Ensure();
|
DapperSetup.Ensure();
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
_write = new SqliteConnection(BuildConnectionString(_path));
|
_write = new SqliteConnection(BuildConnectionString(_path));
|
||||||
@@ -72,8 +76,17 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
|
|||||||
_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()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
if (_write is not null)
|
if (_write is not null)
|
||||||
{
|
{
|
||||||
@@ -81,8 +94,13 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
|
|||||||
await _write.DisposeAsync().ConfigureAwait(false);
|
await _write.DisposeAsync().ConfigureAwait(false);
|
||||||
_write = null;
|
_write = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
_opened = false;
|
_opened = false;
|
||||||
|
_lock?.Dispose();
|
||||||
|
_lock = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default)
|
public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
60
tests/Explorer.Application.Tests/WorkbenchHostTests.cs
Normal file
60
tests/Explorer.Application.Tests/WorkbenchHostTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs
Normal file
48
tests/Explorer.Hosting.Tests/CoreRegistrationTests.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj
Normal file
22
tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj
Normal 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>
|
||||||
19
tests/Explorer.Hosting.Tests/HostLogonAutostartTests.cs
Normal file
19
tests/Explorer.Hosting.Tests/HostLogonAutostartTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs
Normal file
76
tests/Explorer.Hosting.Tests/WorkbenchPipeTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user