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:
@@ -1,21 +1,11 @@
|
||||
using Explorer.Analysis;
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Indexing;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Plugin.GoogleDrive;
|
||||
using Explorer.Plugin.Nextcloud;
|
||||
using Explorer.Plugin.OneDrive;
|
||||
using Explorer.Hosting;
|
||||
using Explorer.Presentation;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
using Explorer.Search;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Explorer.Windows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
@@ -23,105 +13,20 @@ public static class AppServices
|
||||
{
|
||||
public static IServiceCollection AddExplorer(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IClock, SystemClock>();
|
||||
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>();
|
||||
services.AddSingleton<IVolumeService, WindowsVolumeService>();
|
||||
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
|
||||
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
|
||||
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
|
||||
services.AddExplorerCore();
|
||||
services.AddExplorerUi();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddExplorerUi(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
|
||||
services.AddSingleton<IIndexStore>(sp =>
|
||||
{
|
||||
var env = sp.GetRequiredService<IAppEnvironment>();
|
||||
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
||||
return new SqliteIndexStore(env.DatabasePath, logger);
|
||||
});
|
||||
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
|
||||
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
|
||||
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
|
||||
services.AddSingleton<StorageProviderRegistry>();
|
||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
||||
services.AddSingleton<WindowsGitStatusProvider>();
|
||||
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
|
||||
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
|
||||
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
|
||||
services.AddSingleton<SourceManager>();
|
||||
services.AddSingleton<PathHistoryStore>();
|
||||
services.AddSingleton<CloudPlaceStore>();
|
||||
services.AddSingleton<UiPreferencesStore>();
|
||||
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
|
||||
services.AddSingleton<ArchiveContentsIndexer>();
|
||||
services.AddSingleton<BrowseService>();
|
||||
services.AddSingleton<ThumbnailService>();
|
||||
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>());
|
||||
services.AddSingleton<FilesystemScanner>();
|
||||
services.AddSingleton<FolderReconciler>();
|
||||
services.AddSingleton<UsnChangeApplier>();
|
||||
services.AddSingleton<IndexingCoordinator>();
|
||||
services.AddSingleton<DirectoryWatcherHub>();
|
||||
services.AddSingleton<SearchService>();
|
||||
services.AddSingleton<AnalysisService>();
|
||||
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
|
||||
services.AddSingleton<TransferQueue>();
|
||||
services.AddSingleton<FileOperationService>();
|
||||
services.AddSingleton<RenamePlanner>();
|
||||
services.AddSingleton<RenameBatchService>();
|
||||
services.AddSingleton<FolderSyncPlanner>();
|
||||
services.AddSingleton<FolderSyncService>();
|
||||
services.AddSingleton<FileOperationProfilePlanner>();
|
||||
services.AddSingleton<OperationProfileService>();
|
||||
services.AddSingleton<ReorganizePlanner>();
|
||||
services.AddSingleton<ReorganizeService>();
|
||||
services.AddSingleton<DuplicateHashWorker>();
|
||||
services.AddSingleton<HistoryRollupService>();
|
||||
services.AddSingleton<MainViewModel>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<ThumbnailService>());
|
||||
services.AddHostedService<WatcherHostedService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WatcherHostedService : BackgroundService
|
||||
{
|
||||
private readonly DirectoryWatcherHub _hub;
|
||||
private readonly SourceManager _sources;
|
||||
private readonly TransferQueue _transfers;
|
||||
private readonly FolderSyncService _sync;
|
||||
private readonly OperationProfileService _profiles;
|
||||
|
||||
public WatcherHostedService(
|
||||
DirectoryWatcherHub hub,
|
||||
SourceManager sources,
|
||||
TransferQueue transfers,
|
||||
FolderSyncService sync,
|
||||
OperationProfileService profiles)
|
||||
{
|
||||
_hub = hub;
|
||||
_sources = sources;
|
||||
_transfers = transfers;
|
||||
_sync = sync;
|
||||
_profiles = profiles;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
|
||||
_transfers.NotifyAvailability();
|
||||
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
|
||||
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
|
||||
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Host\Explorer.Host.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\Explorer.Hosting\Explorer.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
|
||||
@@ -39,4 +43,17 @@
|
||||
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyExplorerHost" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))</_HostDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<_HostFiles Include="$(_HostDir)Explorer.Host.exe" />
|
||||
<_HostFiles Include="$(_HostDir)Explorer.Host.dll" />
|
||||
<_HostFiles Include="$(_HostDir)Explorer.Host.deps.json" />
|
||||
<_HostFiles Include="$(_HostDir)Explorer.Host.runtimeconfig.json" />
|
||||
<_HostFiles Include="$(_HostDir)Explorer.Host.pdb" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
Content="Include archive contents in the index"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the 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 TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using Explorer.Application;
|
||||
using Explorer.Hosting;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
|
||||
namespace Explorer.App;
|
||||
@@ -20,6 +21,8 @@ public partial class SettingsWindow : Window
|
||||
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
|
||||
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
|
||||
IndexArchives.IsChecked = prefs.IndexArchiveContents;
|
||||
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
|
||||
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
|
||||
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
|
||||
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
|
||||
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
|
||||
@@ -45,6 +48,8 @@ public partial class SettingsWindow : Window
|
||||
GroupNetworkPlaces = GroupNetwork.IsChecked == true,
|
||||
GroupCloudPlaces = GroupCloud.IsChecked == true,
|
||||
IndexArchiveContents = IndexArchives.IsChecked == true,
|
||||
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
|
||||
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
|
||||
ShowHiddenFiles = ShowHidden.IsChecked == true,
|
||||
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
|
||||
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
|
||||
@@ -52,10 +57,41 @@ public partial class SettingsWindow : Window
|
||||
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim()
|
||||
};
|
||||
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
|
||||
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ApplyBackgroundHostAutostart(bool enabled)
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
var exe = HostLogonAutostart.FindHostExecutable();
|
||||
if (exe is null)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Explorer.Host.exe was not found next to Explorer Workbench, so the sign-in task was not registered.",
|
||||
"Background host",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HostLogonAutostart.TryRegister(exe, out var error))
|
||||
{
|
||||
MessageBox.Show(this, error, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HostLogonAutostart.TryUnregister(out var unregisterError))
|
||||
{
|
||||
MessageBox.Show(this, unregisterError, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether this process already has administrator rights.
|
||||
/// Elevation is never requested automatically. A future helper may scan USN
|
||||
/// under elevation; it must not host the transfer queue or index writer.
|
||||
/// </summary>
|
||||
public interface IElevatedScanService
|
||||
{
|
||||
bool IsElevated { get; }
|
||||
|
||||
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? OrganizeInstallers = null,
|
||||
string? OrganizeArchives = null,
|
||||
string? OrganizeDevelopment = null)
|
||||
string? OrganizeDevelopment = null,
|
||||
bool AutoIndexRemovable = false,
|
||||
bool BackgroundHostAtLogon = false)
|
||||
{
|
||||
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
||||
}
|
||||
@@ -69,6 +71,8 @@ public sealed class UiPreferencesStore
|
||||
"show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
|
||||
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
|
||||
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
|
||||
"auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
|
||||
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
|
||||
.. SevenZipLines(preferences),
|
||||
.. GitLines(preferences),
|
||||
.. OrganizeLines(preferences),
|
||||
@@ -90,6 +94,8 @@ public sealed class UiPreferencesStore
|
||||
var showHidden = true;
|
||||
var showProtected = false;
|
||||
var autoClearQueue = false;
|
||||
var autoIndexRemovable = false;
|
||||
var backgroundHostAtLogon = false;
|
||||
string? sevenZipPath = null;
|
||||
string? gitPath = null;
|
||||
string? organizePictures = null;
|
||||
@@ -149,6 +155,14 @@ public sealed class UiPreferencesStore
|
||||
{
|
||||
autoClearQueue = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("auto-index-removable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
autoIndexRemovable = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("background-host-at-logon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
backgroundHostAtLogon = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
@@ -215,7 +229,7 @@ public sealed class UiPreferencesStore
|
||||
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
|
||||
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
|
||||
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
|
||||
organizeDevelopment);
|
||||
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
||||
|
||||
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>
|
||||
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -5,7 +6,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.FileOperations;
|
||||
|
||||
public sealed class TransferQueue : BackgroundService
|
||||
public sealed class TransferQueue : BackgroundService, ITransferHost
|
||||
{
|
||||
private readonly IOperationExecutor _executor;
|
||||
private readonly IIndexStore _store;
|
||||
|
||||
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>
|
||||
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -6,7 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.Indexing;
|
||||
|
||||
public sealed class IndexingCoordinator : BackgroundService
|
||||
public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private readonly FilesystemScanner _scanner;
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Indexing;
|
||||
|
||||
namespace Explorer.Presentation.ViewModels;
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
{
|
||||
private readonly BrowseService _browse;
|
||||
private readonly FileOperationService _ops;
|
||||
private readonly IndexingCoordinator _indexing;
|
||||
private readonly IIndexingHost _indexing;
|
||||
private readonly SourceManager _sources;
|
||||
private readonly IGitStatusProvider _git;
|
||||
private readonly IThumbnailService? _thumbnails;
|
||||
@@ -43,7 +43,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
public ExplorerPaneViewModel(
|
||||
BrowseService browse,
|
||||
FileOperationService ops,
|
||||
IndexingCoordinator indexing,
|
||||
IIndexingHost indexing,
|
||||
SourceManager sources,
|
||||
IGitStatusProvider git,
|
||||
IThumbnailService? thumbnails = null)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Indexing;
|
||||
|
||||
namespace Explorer.Presentation.ViewModels;
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
|
||||
public ExplorerTabViewModel(
|
||||
BrowseService browse,
|
||||
FileOperationService ops,
|
||||
IndexingCoordinator indexing,
|
||||
IIndexingHost indexing,
|
||||
SourceManager sources,
|
||||
IGitStatusProvider git,
|
||||
IThumbnailService? thumbnails = null)
|
||||
|
||||
@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Indexing;
|
||||
using Explorer.Search;
|
||||
using Explorer.Analysis;
|
||||
using Explorer.Domain.Abstractions;
|
||||
@@ -16,7 +16,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly BrowseService _browse;
|
||||
private readonly FileOperationService _ops;
|
||||
private readonly IndexingCoordinator _indexing;
|
||||
private readonly IIndexingHost _indexing;
|
||||
private readonly SourceManager _sources;
|
||||
private readonly PathHistoryStore _pathHistory;
|
||||
private readonly StorageProviderRegistry _providers;
|
||||
@@ -63,12 +63,11 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
public MainViewModel(
|
||||
BrowseService browse,
|
||||
FileOperationService ops,
|
||||
IndexingCoordinator indexing,
|
||||
SourceManager sources,
|
||||
SearchService search,
|
||||
AnalysisService analysis,
|
||||
IIndexStore store,
|
||||
TransferQueue transfers,
|
||||
IWorkbenchHost workbench,
|
||||
IOsClipboard clipboard,
|
||||
PathHistoryStore pathHistory,
|
||||
StorageProviderRegistry providers,
|
||||
@@ -88,7 +87,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
_browse = browse;
|
||||
_ops = ops;
|
||||
_indexing = indexing;
|
||||
_indexing = workbench.Indexing;
|
||||
_sources = sources;
|
||||
_pathHistory = pathHistory;
|
||||
_providers = providers;
|
||||
@@ -112,10 +111,10 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
Analysis = new AnalysisViewModel(analysis);
|
||||
Duplicates = new DuplicateViewModel(store, sources, analysis);
|
||||
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
|
||||
Transfers = new TransferQueueViewModel(transfers, preferences);
|
||||
Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
|
||||
Tabs = [];
|
||||
Clipboard = clipboard;
|
||||
transfers.JobFinished += (_, job) =>
|
||||
workbench.Transfers.JobFinished += (_, job) =>
|
||||
{
|
||||
void Go() => _ = OnTransferFinishedAsync(job);
|
||||
if (_ui is { } ctx)
|
||||
|
||||
@@ -2,8 +2,8 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.FileOperations;
|
||||
|
||||
namespace Explorer.Presentation.ViewModels;
|
||||
|
||||
@@ -186,7 +186,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
|
||||
|
||||
public sealed partial class TransferQueueViewModel : ObservableObject
|
||||
{
|
||||
private readonly TransferQueue _queue;
|
||||
private readonly ITransferHost _queue;
|
||||
private readonly UiPreferencesStore _preferences;
|
||||
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
||||
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = [];
|
||||
@@ -205,7 +205,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
|
||||
[ObservableProperty] private double _overallProgress;
|
||||
[ObservableProperty] private bool _hasOverallProgress;
|
||||
|
||||
public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences)
|
||||
public TransferQueueViewModel(ITransferHost queue, UiPreferencesStore preferences)
|
||||
{
|
||||
_queue = queue;
|
||||
_preferences = preferences;
|
||||
|
||||
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 AsyncLocal<int> _writeDepth = new();
|
||||
private SqliteConnection? _write;
|
||||
private IndexStoreLock? _lock;
|
||||
private bool _opened;
|
||||
private int _analysisIndexesReady;
|
||||
|
||||
@@ -58,31 +59,48 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
DapperSetup.Ensure();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
_write = new SqliteConnection(BuildConnectionString(_path));
|
||||
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
ApplyPragmas(_write);
|
||||
Migrate(_write);
|
||||
if (IndexExists(_write, "ix_entries_dir_agg_all"))
|
||||
_lock = IndexStoreLock.Acquire(_path);
|
||||
try
|
||||
{
|
||||
Volatile.Write(ref _analysisIndexesReady, 1);
|
||||
}
|
||||
DapperSetup.Ensure();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
_write = new SqliteConnection(BuildConnectionString(_path));
|
||||
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
ApplyPragmas(_write);
|
||||
Migrate(_write);
|
||||
if (IndexExists(_write, "ix_entries_dir_agg_all"))
|
||||
{
|
||||
Volatile.Write(ref _analysisIndexesReady, 1);
|
||||
}
|
||||
|
||||
_opened = true;
|
||||
_logger.LogInformation("Opened index database at {Path}", _path);
|
||||
_opened = true;
|
||||
_logger.LogInformation("Opened index database at {Path}", _path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_lock.Dispose();
|
||||
_lock = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
if (_write is not null)
|
||||
try
|
||||
{
|
||||
await _write.CloseAsync().ConfigureAwait(false);
|
||||
await _write.DisposeAsync().ConfigureAwait(false);
|
||||
_write = null;
|
||||
if (_write is not null)
|
||||
{
|
||||
await _write.CloseAsync().ConfigureAwait(false);
|
||||
await _write.DisposeAsync().ConfigureAwait(false);
|
||||
_write = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_opened = false;
|
||||
_lock?.Dispose();
|
||||
_lock = null;
|
||||
}
|
||||
|
||||
_opened = false;
|
||||
}
|
||||
|
||||
public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class WindowsElevatedScanService : IElevatedScanService
|
||||
}
|
||||
}
|
||||
|
||||
// Queue, index writer, and watchers stay in the user session. Elevation is never requested.
|
||||
public bool CanRequestElevation => false;
|
||||
|
||||
public string ProtectedContentHint => "Protected content requires administrator privileges.";
|
||||
|
||||
Reference in New Issue
Block a user