Add queued FFmpeg conversion and finish splitting the window from the host.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 01:49:50 +02:00
parent 48d03f794f
commit 6fc7506eb7
85 changed files with 3394 additions and 512 deletions

View File

@@ -1,11 +1,14 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
using Explorer.Hosting;
using Explorer.Hosting.Ipc;
using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Extensions.Logging;
namespace Explorer.App;
@@ -14,8 +17,10 @@ public partial class App : System.Windows.Application
private IHost? _host;
private WorkbenchPipeClient? _workbenchClient;
protected override async void OnStartup(StartupEventArgs e)
protected override void OnStartup(StartupEventArgs e)
{
// Closing the splash must not quit the process before the main window exists.
ShutdownMode = ShutdownMode.OnExplicitShutdown;
base.OnStartup(e);
DispatcherUnhandledException += (_, args) =>
{
@@ -32,53 +37,80 @@ public partial class App : System.Windows.Application
retainedFileCountLimit: 14)
.CreateLogger();
_workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync(
TimeSpan.FromSeconds(12),
logger: null).ConfigureAwait(true);
var hostExe = HostLogonAutostart.FindHostExecutable();
if (_workbenchClient is null && hostExe is not null)
{
MessageBox.Show(
"Explorer.Host.exe is present but the window could not connect to it. See logs.",
"Explorer Workbench",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(-1);
return;
}
var splash = ShowStartupSplash();
_ = StartWorkbenchAsync(splash);
}
_host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((_, services) =>
private async Task StartWorkbenchAsync(Window splash)
{
using var loggerFactory = new SerilogLoggerFactory(Log.Logger);
try
{
try
{
if (_workbenchClient is not null)
var logger = loggerFactory.CreateLogger("HostConnector");
_workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync(
TimeSpan.FromSeconds(60),
logger)
.ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Could not connect to Explorer.Host.exe");
}
if (_workbenchClient is null)
{
var hostExe = HostLogonAutostart.FindHostExecutable();
MessageBox.Show(
hostExe is null
? "Explorer.Host.exe was not found beside Explorer.App.exe. Copy the host executable next to the window, then start again."
: "Explorer.Host.exe did not accept a connection in time. The host process you just started is still initializing; wait until its CPU usage drops, then start Explorer.App.exe again. You do not need to close Explorer.Host.",
"Explorer Workbench",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(-1);
return;
}
_host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((_, services) =>
{
services.AddExplorerClient(_workbenchClient);
services.AddExplorerUi();
}
else
{
services.AddExplorer();
}
})
.Build();
})
.Build();
var vm = _host.Services.GetRequiredService<MainViewModel>();
var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi();
window.DataContext = vm;
window.Show();
try
{
await vm.InitializeAsync().ConfigureAwait(true);
var vm = _host.Services.GetRequiredService<MainViewModel>();
var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi();
window.DataContext = vm;
MainWindow = window;
window.Show();
ShutdownMode = ShutdownMode.OnMainWindowClose;
try
{
await vm.InitializeAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Startup initialization failed");
vm.Footer = "Started with errors. See logs.";
}
await _host.StartAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Startup initialization failed");
vm.Footer = "Started with errors. See logs.";
Log.Error(ex, "Could not start Explorer Workbench");
Shutdown(-1);
}
finally
{
splash.Close();
}
await _host.StartAsync().ConfigureAwait(true);
}
protected override async void OnExit(ExitEventArgs e)
@@ -97,4 +129,27 @@ public partial class App : System.Windows.Application
Log.CloseAndFlush();
base.OnExit(e);
}
private static Window ShowStartupSplash()
{
var splash = new Window
{
Title = "Explorer Workbench",
Width = 420,
Height = 120,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.ToolWindow,
ShowInTaskbar = true,
Content = new TextBlock
{
Text = "Starting Explorer Workbench…",
Margin = new Thickness(20),
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center
}
};
splash.Show();
return splash;
}
}

View File

@@ -1,6 +1,5 @@
using Explorer.Application;
using Explorer.Domain.Abstractions;
using Explorer.Hosting;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Explorer.Windows;
@@ -11,13 +10,6 @@ namespace Explorer.App;
public static class AppServices
{
public static IServiceCollection AddExplorer(this IServiceCollection services)
{
services.AddExplorerCore();
services.AddExplorerUi();
return services;
}
public static IServiceCollection AddExplorerUi(this IServiceCollection services)
{
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();

View File

@@ -0,0 +1,47 @@
<Window x:Class="Explorer.App.ConvertWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Convert"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="560" Width="820"
MinHeight="420" MinWidth="640"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Conversion" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding Kinds}" DisplayMemberPath="Label" SelectedValuePath="Kind"
SelectedValue="{Binding Kind}" Margin="0,0,0,16"/>
<TextBlock Text="Destination" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDest" Margin="8,0,0,0"/>
<TextBox Text="{Binding DestPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
Text="FFmpeg is not bundled. Jobs run on the background host through the File Operations Queue. Online-only cloud files are skipped. Output names are unique so existing files are not overwritten."/>
</StackPanel>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Action" Width="90" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Output" Width="240" DisplayMemberBinding="{Binding Path}"/>
<GridViewColumn Header="Source" Width="160" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,41 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class ConvertWindow : Window
{
public ConvertWindow(ConvertViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) =>
{
try
{
DialogResult = true;
}
catch (InvalidOperationException)
{
// not shown as a dialog
}
Close();
};
}
private void OnBrowseDest(object sender, RoutedEventArgs e)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Convert to",
Multiselect = false
};
if (picker.ShowDialog(this) == true
&& !string.IsNullOrWhiteSpace(picker.FolderName)
&& DataContext is ConvertViewModel vm)
{
vm.DestPath = picker.FolderName;
}
}
}

View File

@@ -22,25 +22,17 @@
<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.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<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>
<GlobalPropertiesToRemove>SelfContained;RuntimeIdentifier;PublishSingleFile</GlobalPropertiesToRemove>
</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" />
<ProjectReference Include="..\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.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>
<Target Name="CopyExplorerHost" AfterTargets="Build">
@@ -48,12 +40,8 @@
<_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" />
<_HostFiles Include="$(_HostDir)*.*" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
</ItemGroup>
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="'@(_HostFiles)' != ''" />
</Target>
</Project>

View File

@@ -47,6 +47,8 @@
<MenuItem Header="New _tab" InputGestureText="Ctrl+T" Command="{Binding NewTabCommand}"/>
<MenuItem Header="_Split pane" Command="{Binding SplitCommand}"/>
<MenuItem Header="_Close tab" InputGestureText="Ctrl+W" Command="{Binding CloseTabCommand}" CommandParameter="{Binding ActiveTab}"/>
<Separator/>
<MenuItem Header="Stop _background host…" Click="OnStopBackgroundHost"/>
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Details" Command="{Binding SetViewCommand}" CommandParameter="Details"/>
@@ -86,6 +88,8 @@
<MenuItem Header="_Verify archive" Click="OnVerifyArchive"
IsEnabled="{Binding ShowVerifyArchive}"/>
</MenuItem>
<MenuItem Header="_Convert…" Click="OnConvert"
IsEnabled="{Binding ShowConvert}"/>
<MenuItem Header="_Organize folder…" Click="OnOrganizeFolder"/>
</MenuItem>
<MenuItem Header="_Automation">
@@ -263,7 +267,7 @@
<TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center" Text="File operations queue"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="11" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
Text="Copy, move, delete, and queued rename run one at a time. Jobs wait if the destination is offline, and failed steps can be retried. Pause a queued step to skip it, or reorder with the arrows."
Text="Copy, move, delete, convert, and queued rename run one at a time. Jobs wait if the destination is offline, and failed steps can be retried. Pause a queued step to skip it, or reorder with the arrows."
TextWrapping="Wrap"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}">
@@ -471,6 +475,8 @@
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Convert…" Click="OnConvert"
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>

View File

@@ -1195,6 +1195,21 @@ public partial class MainWindow : Window
private async void OnVerifyArchive(object sender, RoutedEventArgs e)
=> await Vm.VerifySelectedAsync().ConfigureAwait(true);
private void OnConvert(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateConvertViewModel();
if (vm is null)
{
return;
}
var dlg = new ConvertWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Convert queued.";
}
}
private async void OnFolderSync(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateFolderSyncViewModel();
@@ -1474,6 +1489,33 @@ public partial class MainWindow : Window
return null;
}
private async void OnStopBackgroundHost(object sender, RoutedEventArgs e)
{
if (!Vm.CanStopBackgroundHost)
{
MessageBox.Show(
this,
"The background host is not connected.",
"Explorer Workbench",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var confirm = MessageBox.Show(
this,
"Stop the background host? Indexing and the file operations queue will stop until you start Explorer Workbench again.",
"Explorer Workbench",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK)
{
return;
}
await Vm.StopBackgroundHostAsync().ConfigureAwait(true);
}
private async void OnOpenSettings(object sender, RoutedEventArgs e)
{
var dlg = new SettingsWindow(Vm) { Owner = this };

View File

@@ -82,11 +82,15 @@
<ComboBox ItemsSource="{Binding Formats}" DisplayMemberPath="Label" SelectedValuePath="Format"
SelectedValue="{Binding ArchiveFormat}" Margin="0,0,0,8"
IsEnabled="{Binding CompressOptionsEnabled}"/>
<CheckBox Content="Convert" IsChecked="{Binding DoConvert}" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding ConversionKinds}" DisplayMemberPath="Label" SelectedValuePath="Kind"
SelectedValue="{Binding ConversionKind}" Margin="0,0,0,8"
IsEnabled="{Binding ConvertOptionsEnabled}"/>
<CheckBox Content="Copy to destination" IsChecked="{Binding DoCopy}" Margin="0,0,0,8"/>
<CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Auto-run is Copy only — not Rename or Compress. Drive letters can change; the volume identity is stored."/>
Text="Auto-run is Copy only — not Rename, Compress, or Convert. Drive letters can change; the volume identity is stored."/>
<TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>

View File

@@ -70,7 +70,7 @@
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
Content="Start Explorer.Host.exe at Windows sign-in"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="Registers a per-user logon task. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write."/>
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
<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"
@@ -87,6 +87,14 @@
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/>
</DockPanel>
<TextBlock Text="FFmpeg" FontSize="16" FontWeight="SemiBold" Margin="0,16,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Convert uses ffmpeg.exe from a Windows zip/build (ffprobe and ffplay are not required). Leave the path empty to look in Program Files\ffmpeg\bin and PATH. FFmpeg is not bundled with Explorer Workbench."/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseFfmpeg" Margin="8,0,0,0"/>
<TextBox x:Name="FfmpegPath"/>
</DockPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>

View File

@@ -28,6 +28,7 @@ public partial class SettingsWindow : Window
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
SevenZipPath.Text = prefs.SevenZipPath ?? "";
GitPath.Text = prefs.GitPath ?? "";
FfmpegPath.Text = prefs.FfmpegPath ?? "";
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
@@ -54,7 +55,8 @@ public partial class SettingsWindow : Window
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(),
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim()
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim(),
FfmpegPath = string.IsNullOrWhiteSpace(FfmpegPath.Text) ? null : FfmpegPath.Text.Trim()
};
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
@@ -120,6 +122,20 @@ public partial class SettingsWindow : Window
}
}
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "FFmpeg executable",
Filter = "FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
FileName = FfmpegPath.Text
};
if (dlg.ShowDialog(this) == true)
{
FfmpegPath.Text = dlg.FileName;
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
_vm.Theme = _originalTheme;

View File

@@ -11,7 +11,7 @@ public sealed class BrowseService
private readonly IVolumeService _volumes;
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly StorageProviderRegistry _providers;
private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly IElevatedScanService? _elevation;
@@ -22,7 +22,7 @@ public sealed class BrowseService
IVolumeService volumes,
IIndexStore store,
SourceManager sources,
StorageProviderRegistry providers,
ICloudOverlay providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences,
IElevatedScanService? elevation = null,
@@ -375,7 +375,7 @@ public sealed class BrowseService
yield return new BrowseDelta { Path = path, Updated = accessUpdates };
}
var constrained = _providers.Find(path) is not null;
var constrained = _providers.FindProviderId(path) is not null;
if (constrained)
{
await foreach (var enriched in EnrichInBatchesAsync(all, byPath, viewport, source?.Kind, constrained: true, cancellationToken)

View File

@@ -0,0 +1,148 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class ConversionPlanner
{
public OperationPlan Build(
IReadOnlyList<string> sourcePaths,
string destDirectory,
ConversionKind kind,
IFileSystemEnumerator enumerator,
bool ffmpegAvailable,
string missingHint,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
{
var issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>();
var sources = sourcePaths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToList();
if (sources.Count == 0)
{
return Error("Select files or a folder to convert.");
}
if (string.IsNullOrWhiteSpace(destDirectory))
{
return Error("Choose a destination folder.");
}
if (!ffmpegAvailable)
{
return Error(missingHint);
}
var files = Collect(sources, enumerator, wouldHydrate, issues);
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
var operations = new List<PlannedOperation>();
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in files)
{
if (!ConversionFormats.Matches(item.Name, kind))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Skipped — not a match for this conversion.", item.FullPath));
continue;
}
var dest = UniqueOutputPath(destDirectory, Path.GetFileNameWithoutExtension(item.Name), ConversionFormats.Extension(kind), pathExists, claimed);
claimed.Add(dest);
operations.Add(new PlannedOperation(TransferOp.Convert, item.FullPath, dest, kind.ToString()));
preview.Add(new ProfilePreviewRow("Convert", dest, item.Name));
}
if (operations.Count == 0)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Nothing to convert for this conversion kind."));
}
return new OperationPlan
{
Operations = issues.Any(i => i.Severity == PlanIssueSeverity.Error) ? [] : operations,
Issues = issues,
ProfilePreview = preview
};
}
public static string UniqueOutputPath(
string directory,
string stem,
string extension,
Func<string, bool>? pathExists,
ISet<string>? claimed)
{
extension = extension.Trim().TrimStart('.');
var dest = PathRules.Combine(directory, stem + "." + extension);
var i = 2;
while (IsTaken(dest, pathExists, claimed))
{
dest = PathRules.Combine(directory, $"{stem} ({i++}).{extension}");
}
return dest;
}
private static bool IsTaken(string dest, Func<string, bool>? pathExists, ISet<string>? claimed)
=> claimed?.Contains(dest) == true || pathExists?.Invoke(dest) == true;
private static List<FileSystemItem> Collect(
IReadOnlyList<string> sources,
IFileSystemEnumerator enumerator,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues)
{
var items = new List<FileSystemItem>();
foreach (var path in sources)
{
var item = enumerator.GetItem(path);
if (item is null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Source was not found.", path));
continue;
}
if (item.IsDirectory)
{
var children = enumerator.EnumerateChildrenSafe(item.FullPath, out var error);
if (error is not null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error, item.FullPath));
continue;
}
foreach (var child in children.Where(c => !c.IsDirectory))
{
Add(child, wouldHydrate, issues, items);
}
}
else
{
Add(item, wouldHydrate, issues, items);
}
}
return items;
}
private static void Add(
FileSystemItem item,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues,
List<FileSystemItem> items)
{
if (wouldHydrate?.Invoke(item) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", item.FullPath));
return;
}
items.Add(item);
}
private static OperationPlan Error(string message, string? path = null)
=> new() { Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)] };
}

View File

@@ -0,0 +1,41 @@
namespace Explorer.Application;
public static class FfmpegLocator
{
public const string MissingHint = "ffmpeg.exe was not found. Place a Windows build on PATH, under Program Files\\ffmpeg\\bin, or set the path in Settings.";
public static string? Find(string? configuredPath, Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
if (!string.IsNullOrWhiteSpace(configuredPath) && fileExists(configuredPath.Trim()))
{
return configuredPath.Trim();
}
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(programFiles, "ffmpeg", "bin", "ffmpeg.exe");
yield return Path.Combine(programFiles, "FFmpeg", "bin", "ffmpeg.exe");
yield return Path.Combine(programFilesX86, "ffmpeg", "bin", "ffmpeg.exe");
yield return Path.Combine(local, "Microsoft", "WinGet", "Links", "ffmpeg.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "ffmpeg.exe");
}
}
}

View File

@@ -19,7 +19,9 @@ public sealed class FileOperationProfilePlanner
bool compressAvailable,
string compressMissingHint,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
Func<FileSystemItem, bool>? wouldHydrate = null,
bool convertAvailable = true,
string? convertMissingHint = null)
{
var issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>();
@@ -29,9 +31,9 @@ public sealed class FileOperationProfilePlanner
return Error("Choose a source folder or drop files onto the profile.");
}
if (!profile.DoCopy && !profile.DoCompress && !profile.HasRenameRules)
if (!profile.DoCopy && !profile.DoCompress && !profile.DoConvert && !profile.HasRenameRules)
{
return Error("Turn on Copy, Compress, or Rename.");
return Error("Turn on Copy, Compress, Convert, or Rename.");
}
if (profile.RequireGitClean)
@@ -52,7 +54,7 @@ public sealed class FileOperationProfilePlanner
}
}
var needsDest = profile.DoCopy || profile.DoCompress;
var needsDest = profile.DoCopy || profile.DoCompress || profile.DoConvert;
var destRoot = profile.DestPath?.Trim() ?? "";
if (needsDest)
{
@@ -144,6 +146,44 @@ public sealed class FileOperationProfilePlanner
preview.Add(new ProfilePreviewRow("Compress", archive, $"{working.Count} item(s)"));
}
if (profile.DoConvert)
{
if (!convertAvailable)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, convertMissingHint ?? FfmpegLocator.MissingHint));
return new OperationPlan { Issues = issues, ProfilePreview = preview, Preview = [] };
}
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var converted = 0;
foreach (var path in working)
{
var name = PathRules.GetFileName(path);
if (!ConversionFormats.Matches(name, profile.ConversionKind))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Skipped — not a match for this conversion.", path));
continue;
}
var dest = ConversionPlanner.UniqueOutputPath(
destRoot,
Path.GetFileNameWithoutExtension(name),
ConversionFormats.Extension(profile.ConversionKind),
pathExists,
claimed);
claimed.Add(dest);
operations.Add(new PlannedOperation(TransferOp.Convert, path, dest, profile.ConversionKind.ToString()));
preview.Add(new ProfilePreviewRow("Convert", dest, name));
converted++;
}
if (converted == 0)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Nothing to convert for this conversion kind."));
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
}
if (profile.DoCopy)
{
var copyDest = payload.ContainerName is null

View File

@@ -12,9 +12,9 @@ public interface IHydrationGuard
public sealed class HydrationGuard : IHydrationGuard
{
private readonly StorageProviderRegistry _registry;
private readonly ICloudOverlay _overlay;
public HydrationGuard(StorageProviderRegistry registry) => _registry = registry;
public HydrationGuard(ICloudOverlay overlay) => _overlay = overlay;
public bool WouldHydrateOnRead(FileSystemItem item)
{
@@ -38,7 +38,7 @@ public sealed class HydrationGuard : IHydrationGuard
public async Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
{
var state = await _registry.GetStateAsync(path, cancellationToken).ConfigureAwait(false);
var state = await _overlay.GetStateAsync(path, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return false;

View File

@@ -0,0 +1,20 @@
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Application;
public interface ICloudOverlay
{
IReadOnlyList<ProviderPlace> GetPlaces();
string? FindProviderId(string path);
bool HasCapability(string path, ProviderCapability capability);
Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
IReadOnlyList<FileSystemItem> items,
CancellationToken cancellationToken = default);
Task<ProviderActionResult> InvokeAsync(
ProviderAction action,
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default);
Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default);
Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,18 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed record ConversionProgress(int Percent, string? CurrentPath);
public interface IMediaConversionProvider
{
bool IsAvailable { get; }
string MissingHint { get; }
Task ConvertAsync(
string sourcePath,
string destinationPath,
ConversionKind kind,
IProgress<ConversionProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,55 @@
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Application;
public sealed class IndexStoreLifetime : IHostedService
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly ILogger<IndexStoreLifetime> _logger;
private Task? _initialize;
public IndexStoreLifetime(IIndexStore store, SourceManager sources, ILogger<IndexStoreLifetime> logger)
{
_store = store;
_sources = sources;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
_initialize = InitializeInBackgroundAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_initialize is not null)
{
try
{
await _initialize.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
{
_logger.LogDebug(ex, "Source refresh still running while the host stopped");
}
}
await _store.CloseAsync().ConfigureAwait(false);
}
private async Task InitializeInBackgroundAsync(CancellationToken cancellationToken)
{
try
{
await _sources.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Background source refresh failed");
}
}
}

View File

@@ -0,0 +1,32 @@
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Application;
public sealed class NullCloudOverlay : ICloudOverlay
{
public static NullCloudOverlay Instance { get; } = new();
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
public string? FindProviderId(string path) => null;
public bool HasCapability(string path, ProviderCapability capability) => false;
public Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
IReadOnlyList<FileSystemItem> items,
CancellationToken cancellationToken = default)
=> Task.FromResult(items);
public Task<ProviderActionResult> InvokeAsync(
ProviderAction action,
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported, "No cloud provider is available."));
public Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderItemState?>(null);
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderQuota?>(null);
}

View File

@@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.Application;
public sealed class StorageProviderRegistry
public sealed class StorageProviderRegistry : ICloudOverlay
{
private readonly IReadOnlyList<IStorageProvider> _providers;
private readonly HashSet<string> _disabled = new(StringComparer.OrdinalIgnoreCase);
@@ -30,6 +30,8 @@ public sealed class StorageProviderRegistry
}
}
public string? FindProviderId(string path) => Find(path)?.Manifest.Id;
public IStorageProvider? Find(string path)
{
foreach (var provider in _providers)

View File

@@ -1,7 +1,15 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed record SessionTabState(
string LeftPath,
string? RightPath = null,
bool IsSplit = false,
double SplitRatio = 0.5,
bool ActiveIsRight = false);
public sealed record UiPreferences(
string Theme,
bool GroupNetworkPlaces,
@@ -18,6 +26,7 @@ public sealed record UiPreferences(
double? TreeWidth = null,
string? SevenZipPath = null,
string? GitPath = null,
string? FfmpegPath = null,
string? OrganizePictures = null,
string? OrganizeVideos = null,
string? OrganizeAudio = null,
@@ -26,7 +35,9 @@ public sealed record UiPreferences(
string? OrganizeArchives = null,
string? OrganizeDevelopment = null,
bool AutoIndexRemovable = false,
bool BackgroundHostAtLogon = false)
bool BackgroundHostAtLogon = false,
IReadOnlyList<SessionTabState>? SessionTabs = null,
int SessionActiveTab = 0)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -75,8 +86,10 @@ public sealed class UiPreferencesStore
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
.. SevenZipLines(preferences),
.. GitLines(preferences),
.. FfmpegLines(preferences),
.. OrganizeLines(preferences),
.. LayoutLines(preferences)
.. LayoutLines(preferences),
.. SessionLines(preferences)
]);
}
catch
@@ -98,6 +111,7 @@ public sealed class UiPreferencesStore
var backgroundHostAtLogon = false;
string? sevenZipPath = null;
string? gitPath = null;
string? ffmpegPath = null;
string? organizePictures = null;
string? organizeVideos = null;
string? organizeAudio = null;
@@ -111,6 +125,8 @@ public sealed class UiPreferencesStore
double? windowTop = null;
var windowMaximized = false;
double? treeWidth = null;
var sessionTabs = new List<SessionTabState>();
var sessionActiveTab = 0;
foreach (var raw in lines)
{
var line = raw.Trim();
@@ -171,6 +187,10 @@ public sealed class UiPreferencesStore
{
gitPath = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (key.Equals("ffmpeg", StringComparison.OrdinalIgnoreCase))
{
ffmpegPath = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (key.Equals("organize-pictures", StringComparison.OrdinalIgnoreCase))
{
organizePictures = EmptyToNull(value);
@@ -223,13 +243,30 @@ public sealed class UiPreferencesStore
{
treeWidth = ParseDouble(value);
}
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
&& activeTab >= 0)
{
sessionActiveTab = activeTab;
}
else if (key.Equals("session-tab", StringComparison.OrdinalIgnoreCase)
&& TryParseSessionTab(value) is { } tab
&& sessionTabs.Count < 16)
{
sessionTabs.Add(tab);
}
}
if (sessionTabs.Count > 0)
{
sessionActiveTab = Math.Clamp(sessionActiveTab, 0, sessionTabs.Count - 1);
}
return new UiPreferences(
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, ffmpegPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon);
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon, sessionTabs, sessionActiveTab);
}
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
@@ -248,6 +285,14 @@ public sealed class UiPreferencesStore
}
}
private static IEnumerable<string> FfmpegLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.FfmpegPath))
{
yield return "ffmpeg=" + preferences.FfmpegPath;
}
}
private static IEnumerable<string> OrganizeLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures))
@@ -319,6 +364,74 @@ public sealed class UiPreferencesStore
}
}
private static IEnumerable<string> SessionLines(UiPreferences preferences)
{
var tabs = preferences.SessionTabs;
if (tabs is null || tabs.Count == 0)
{
yield break;
}
yield return "session-active-tab=" + Math.Clamp(preferences.SessionActiveTab, 0, tabs.Count - 1)
.ToString(System.Globalization.CultureInfo.InvariantCulture);
foreach (var tab in tabs.Take(16))
{
yield return "session-tab=" + FormatSessionTab(tab);
}
}
internal static string FormatSessionTab(SessionTabState tab)
{
var ratio = double.IsFinite(tab.SplitRatio) ? tab.SplitRatio : 0.5;
return string.Join(';',
tab.IsSplit ? "1" : "0",
Format(ratio),
tab.ActiveIsRight ? "1" : "0",
Uri.EscapeDataString(string.IsNullOrWhiteSpace(tab.LeftPath) ? LocationRoots.ThisPc : tab.LeftPath),
Uri.EscapeDataString(tab.RightPath ?? ""));
}
internal static SessionTabState? TryParseSessionTab(string value)
{
var parts = value.Split(';', 5);
if (parts.Length < 4)
{
return null;
}
var left = Unescape(parts[3]);
if (string.IsNullOrWhiteSpace(left))
{
left = LocationRoots.ThisPc;
}
var right = parts.Length > 4 ? Unescape(parts[4]) : "";
var ratio = ParseDouble(parts[1]) ?? 0.5;
return new SessionTabState(
left,
string.IsNullOrWhiteSpace(right) ? null : right,
IsTrue(parts[0]) || parts[0] == "1",
ratio,
IsTrue(parts[2]) || parts[2] == "1");
}
private static string Unescape(string value)
{
if (string.IsNullOrEmpty(value))
{
return "";
}
try
{
return Uri.UnescapeDataString(value);
}
catch (UriFormatException)
{
return value;
}
}
private static string Format(double value) => value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
private static double? ParseDouble(string value)

View File

@@ -0,0 +1,8 @@
namespace Explorer.Contracts;
public interface IHostConnection
{
bool IsConnected { get; }
event EventHandler<string>? StatusChanged;
Task RequestShutdownAsync(CancellationToken cancellationToken = default);
}

View File

@@ -53,6 +53,8 @@ public interface ITransferHost
=> Task.CompletedTask;
Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
public interface ISourceHost

View File

@@ -5,7 +5,7 @@ public static class AppConstants
public const string ProductFolderName = "ExplorerWorkbench";
public const string DatabaseFileName = "index.db";
public const string LogFolderName = "logs";
public const int SchemaVersion = 8;
public const int SchemaVersion = 9;
public const int DefaultTombstoneRetentionDays = 30;
public const int ScanBatchSize = 3000;
public const int SearchPageSize = 500;

View File

@@ -0,0 +1,99 @@
namespace Explorer.Domain;
public static class ConversionFormats
{
private static readonly HashSet<string> Videos = new(StringComparer.OrdinalIgnoreCase)
{
"mp4", "mkv", "avi", "mov", "wmv", "webm", "m4v", "mpg", "mpeg", "ts", "mts", "m2ts", "3gp"
};
private static readonly HashSet<string> Audio = new(StringComparer.OrdinalIgnoreCase)
{
"mp3", "wav", "flac", "aac", "m4a", "ogg", "wma", "aiff", "aif", "opus"
};
private static readonly HashSet<string> Heic = new(StringComparer.OrdinalIgnoreCase)
{
"heic", "heif"
};
public static string Extension(ConversionKind kind)
=> kind switch
{
ConversionKind.ExtractAudio => "m4a",
ConversionKind.HeicToJpeg => "jpg",
_ => "mp4"
};
public static string Label(ConversionKind kind)
=> kind switch
{
ConversionKind.ExtractAudio => "Extract audio (AAC / M4A)",
ConversionKind.HeicToJpeg => "HEIC to JPEG",
_ => "Video to H.264 MP4"
};
public static bool Matches(string name, ConversionKind kind)
{
var ext = NameNormalizer.Extension(name);
if (ext is null)
{
return false;
}
return kind switch
{
ConversionKind.VideoToMp4 => Videos.Contains(ext),
ConversionKind.ExtractAudio => Videos.Contains(ext) || Audio.Contains(ext),
ConversionKind.HeicToJpeg => Heic.Contains(ext),
_ => false
};
}
public static bool IsConvertible(string name)
=> Matches(name, ConversionKind.VideoToMp4)
|| Matches(name, ConversionKind.ExtractAudio)
|| Matches(name, ConversionKind.HeicToJpeg);
public static ConversionKind Preferred(IEnumerable<string> names)
{
var list = names.Where(n => !string.IsNullOrWhiteSpace(n)).ToList();
if (list.Any(n => Matches(n, ConversionKind.VideoToMp4)))
{
return ConversionKind.VideoToMp4;
}
if (list.Any(n => Matches(n, ConversionKind.HeicToJpeg)))
{
return ConversionKind.HeicToJpeg;
}
if (list.Any(n => Matches(n, ConversionKind.ExtractAudio)))
{
return ConversionKind.ExtractAudio;
}
return ConversionKind.VideoToMp4;
}
public static ConversionKind Infer(string sourcePath, string destinationPath)
{
var destExt = NameNormalizer.Extension(destinationPath);
if (destExt is "m4a")
{
return ConversionKind.ExtractAudio;
}
if (destExt is "jpg" or "jpeg")
{
return ConversionKind.HeicToJpeg;
}
if (Matches(sourcePath, ConversionKind.HeicToJpeg) && destExt is "jpg" or "jpeg")
{
return ConversionKind.HeicToJpeg;
}
return ConversionKind.VideoToMp4;
}
}

View File

@@ -98,7 +98,8 @@ public enum TransferOp
Extract,
Compress,
AddToArchive,
VerifyArchive
VerifyArchive,
Convert
}
public enum ArchiveFormat
@@ -107,6 +108,13 @@ public enum ArchiveFormat
SevenZip
}
public enum ConversionKind
{
VideoToMp4,
ExtractAudio,
HeicToJpeg
}
public enum TransferStatus
{
Queued,

View File

@@ -9,6 +9,8 @@ public sealed class OperationProfile
public bool RequireGitClean { get; set; }
public bool DoCompress { get; set; }
public ArchiveFormat ArchiveFormat { get; set; } = ArchiveFormat.SevenZip;
public bool DoConvert { get; set; }
public ConversionKind ConversionKind { get; set; } = ConversionKind.VideoToMp4;
public bool DoCopy { get; set; }
public bool DoRename { get; set; }
public string RenamePrefix { get; set; } = "";
@@ -29,7 +31,7 @@ public sealed class OperationProfile
|| !string.IsNullOrWhiteSpace(RenameSuffix)
|| !string.IsNullOrWhiteSpace(RenameSearch));
public bool CanAutoRun => AutoRun && DoCopy && !DoCompress && !HasRenameRules;
public bool CanAutoRun => AutoRun && DoCopy && !DoCompress && !DoConvert && !HasRenameRules;
public RenameRuleSet RenameRules()
=> new()

View File

@@ -4,7 +4,7 @@ using Explorer.Domain;
using SharpCompress.Archives;
using SharpCompress.Readers;
namespace Explorer.Indexing;
namespace Explorer.FileOperations;
public sealed class ArchiveCatalog : IArchiveCatalog
{

View File

@@ -3,8 +3,10 @@
<RootNamespace>Explorer.FileOperations</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="SharpCompress" Version="0.50.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />

View File

@@ -5,7 +5,7 @@ public static class FileOperationErrors
public const string FileInUse = "The file is in use. Retry when it is available.";
public const string NameExists = "A file with that name already exists.";
public const string DestinationUnavailable = "Destination unavailable";
public const string CloudHydration = "Online-only cloud files are not extracted or compressed.";
public const string CloudHydration = "Online-only cloud files are not extracted, compressed, or converted.";
public static bool IsLock(string? error)
{

View File

@@ -0,0 +1,21 @@
using Explorer.Application;
using Microsoft.Extensions.DependencyInjection;
namespace Explorer.FileOperations;
public static class FileOperationRegistration
{
public static IServiceCollection AddExplorerOperations(this IServiceCollection services)
{
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<ConversionPlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
return services;
}
}

View File

@@ -88,6 +88,20 @@ public sealed class FileOperationService
public Task VerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> _queue.EnqueueVerifyArchiveAsync(archivePath, cancellationToken);
public Task ConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> _queue.EnqueueConvertAsync(sourcePath, destinationPath, kind, cancellationToken);
public async Task ConvertAsync(IReadOnlyList<PlannedOperation> operations, CancellationToken cancellationToken = default)
{
foreach (var op in operations.Where(o => o.Op == TransferOp.Convert && o.DestinationPath is not null))
{
var kind = Enum.TryParse<ConversionKind>(op.NewName, true, out var parsed)
? parsed
: ConversionFormats.Infer(op.SourcePath, op.DestinationPath!);
await _queue.EnqueueConvertAsync(op.SourcePath, op.DestinationPath!, kind, cancellationToken).ConfigureAwait(false);
}
}
public static string UniqueArchivePath(string directory, string stem, string extension)
{
extension = extension.Trim().TrimStart('.');

View File

@@ -10,23 +10,26 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
private readonly IFileSystemEnumerator _enumerator;
private readonly IArchiveExecutor? _archives;
private readonly IHydrationGuard? _hydration;
private readonly IMediaConversionProvider? _conversion;
public NativeFileOperationExecutor(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null)
IHydrationGuard? hydration = null,
IMediaConversionProvider? conversion = null)
{
_shell = shell;
_enumerator = enumerator;
_archives = archives;
_hydration = hydration;
_conversion = conversion;
}
public bool CanExecute(TransferOp op)
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress
or TransferOp.AddToArchive or TransferOp.VerifyArchive;
or TransferOp.AddToArchive or TransferOp.VerifyArchive or TransferOp.Convert;
public async Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
{
@@ -53,6 +56,9 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
case TransferOp.VerifyArchive:
await ArchiveAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.Convert:
await ConvertAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
default:
job.Status = TransferStatus.Failed;
job.Error = $"Unsupported operation {job.Op}";
@@ -399,6 +405,74 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
}
}
private async Task ConvertAsync(TransferJob job, Action? reportProgress, CancellationToken cancellationToken)
{
if (_conversion is null || !_conversion.IsAvailable)
{
job.Status = TransferStatus.Failed;
job.Error = _conversion?.MissingHint ?? FfmpegLocator.MissingHint;
return;
}
var dest = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
if (File.Exists(PathRules.ToExtended(dest)) || Directory.Exists(PathRules.ToExtended(dest)))
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.NameExists;
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = job.SourcePath;
var kind = ConversionKindOf(job);
var progress = new Progress<ConversionProgress>(p =>
{
job.BytesTotal = 100;
job.BytesDone = p.Percent;
if (!string.IsNullOrWhiteSpace(p.CurrentPath))
{
job.CurrentPath = p.CurrentPath;
}
reportProgress?.Invoke();
});
try
{
await _conversion.ConvertAsync(job.SourcePath, dest, kind, progress, cancellationToken).ConfigureAwait(false);
job.BytesTotal = 100;
job.BytesDone = 100;
job.FilesDone = 1;
job.CurrentPath = null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
}
private static ConversionKind ConversionKindOf(TransferJob job)
{
if (job.AdditionalSources.Count == 1
&& Enum.TryParse<ConversionKind>(job.AdditionalSources[0], true, out var parsed))
{
return parsed;
}
return ConversionFormats.Infer(job.SourcePath, job.DestinationPath ?? "");
}
private static IReadOnlyList<string> ArchiveSources(TransferJob job)
=> job.AdditionalSources.Count > 0
? job.AdditionalSources

View File

@@ -45,6 +45,7 @@ internal static class OperationAvailability
case TransferOp.Extract:
case TransferOp.Compress:
case TransferOp.AddToArchive:
case TransferOp.Convert:
if (!string.IsNullOrWhiteSpace(job.DestinationPath))
{
yield return PathRules.Parent(job.DestinationPath);

View File

@@ -18,6 +18,7 @@ public sealed class OperationProfileService
private readonly IGitStatusProvider _git;
private readonly IHydrationGuard _hydration;
private readonly IArchiveExecutor _archives;
private readonly IMediaConversionProvider _conversion;
private readonly ConcurrentDictionary<long, bool> _autoRunOnline = [];
public OperationProfileService(
@@ -30,7 +31,8 @@ public sealed class OperationProfileService
IFileSystemEnumerator enumerator,
IGitStatusProvider git,
IHydrationGuard hydration,
IArchiveExecutor archives)
IArchiveExecutor archives,
IMediaConversionProvider conversion)
{
_planner = planner;
_store = store;
@@ -42,6 +44,7 @@ public sealed class OperationProfileService
_git = git;
_hydration = hydration;
_archives = archives;
_conversion = conversion;
}
public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default)
@@ -113,7 +116,9 @@ public sealed class OperationProfileService
_archives.IsAvailable,
_archives.MissingHint,
RenameBatchService.PathExists,
item => _hydration.WouldHydrateOnRead(item));
item => _hydration.WouldHydrateOnRead(item),
_conversion.IsAvailable,
_conversion.MissingHint);
}
public async Task<OperationPlan> EnqueueAsync(
@@ -142,6 +147,13 @@ public sealed class OperationProfileService
var parts = op.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
await _ops.CompressAsync(parts, op.DestinationPath, cancellationToken).ConfigureAwait(false);
}
else if (op.Op == TransferOp.Convert && op.DestinationPath is not null)
{
var kind = Enum.TryParse<ConversionKind>(op.NewName, true, out var parsed)
? parsed
: ConversionFormats.Infer(op.SourcePath, op.DestinationPath);
await _ops.ConvertAsync(op.SourcePath, op.DestinationPath, kind, cancellationToken).ConfigureAwait(false);
}
else if (op.Op == TransferOp.Copy && op.DestinationPath is not null)
{
await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
@@ -151,9 +163,10 @@ public sealed class OperationProfileService
var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy);
var compress = plan.Operations.Count(o => o.Op == TransferOp.Compress);
var convert = plan.Operations.Count(o => o.Op == TransferOp.Convert);
var renamed = renames.Count;
profile.LastRunUtc = DateTimeOffset.UtcNow;
profile.LastStatus = $"Queued {copies} copy, {compress} compress, {renamed} rename";
profile.LastStatus = $"Queued {copies} copy, {compress} compress, {convert} convert, {renamed} rename";
await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return plan;
}
@@ -228,6 +241,14 @@ public sealed class OperationProfileService
IsBuiltIn = true,
CreatedUtc = now
}, cancellationToken).ConfigureAwait(false);
await _mutations.UpsertOperationProfileAsync(new OperationProfile
{
Name = "Convert videos to MP4",
DoConvert = true,
ConversionKind = ConversionKind.VideoToMp4,
IsBuiltIn = true,
CreatedUtc = now
}, cancellationToken).ConfigureAwait(false);
}
private void AttachVolumeGuids(OperationProfile profile)
@@ -252,6 +273,8 @@ public sealed class OperationProfileService
RequireGitClean = profile.RequireGitClean,
DoCompress = profile.DoCompress,
ArchiveFormat = profile.ArchiveFormat,
DoConvert = profile.DoConvert,
ConversionKind = profile.ConversionKind,
DoCopy = profile.DoCopy,
DoRename = profile.DoRename,
RenamePrefix = profile.RenamePrefix,

View File

@@ -157,6 +157,17 @@ public sealed class TransferQueue : BackgroundService, ITransferHost
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Convert,
SourcePath = sourcePath,
DestinationPath = destinationPath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = [kind.ToString()]
}, cancellationToken);
public void PauseAll()
{
_queuePaused = true;

View File

@@ -2,11 +2,14 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Explorer.Host</RootNamespace>
<AssemblyName>Explorer.Host</AssemblyName>
<ApplicationManifest>..\Explorer.App\app.manifest</ApplicationManifest>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\..\explorer-workbench-icons\explorer-workbench.ico</ApplicationIcon>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />

View File

@@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using Explorer.Hosting;
using Explorer.Hosting.Ipc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Extensions.Logging;
namespace Explorer.Host;
internal static class HostEntry
{
public static async Task<int> RunAsync(string[] args, Action<string> boot)
{
using var loggerFactory = new SerilogLoggerFactory(Log.Logger, dispose: false);
var deferred = new DeferredServiceProvider();
var pipe = new WorkbenchPipeServer(
deferred,
new WorkbenchIpcOptions(),
loggerFactory.CreateLogger<WorkbenchPipeServer>());
await pipe.StartAsync(CancellationToken.None).ConfigureAwait(false);
await pipe.Listening.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
boot("Pipe listening");
Log.Information("Named pipe {Pipe} is listening; loading the rest of the host", new WorkbenchIpcOptions().PipeName);
using var stopping = new CancellationTokenSource();
pipe.ShutdownRequested = () => stopping.Cancel();
using var tray = new HostTray(pipe.RequestShutdown);
tray.Start();
try
{
return await RunCoreAsync(args, deferred, boot, stopping.Token).ConfigureAwait(false);
}
finally
{
try
{
await pipe.StopAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Debug(ex, "Pipe server stop");
}
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static async Task<int> RunCoreAsync(
string[] args,
DeferredServiceProvider deferred,
Action<string> boot,
CancellationToken shutdown)
{
if (shutdown.IsCancellationRequested)
{
return 0;
}
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args);
builder.Services.Configure<HostOptions>(options => options.ServicesStartConcurrently = true);
builder.Services.AddExplorerHostProcess();
using var host = builder.Build();
using var stop = shutdown.Register(() =>
host.Services.GetRequiredService<IHostApplicationLifetime>().StopApplication());
if (shutdown.IsCancellationRequested)
{
return 0;
}
await host.StartAsync().ConfigureAwait(false);
deferred.Complete(host.Services);
boot("Core ready");
Log.Information("Host core is ready");
try
{
await host.WaitForShutdownAsync().ConfigureAwait(false);
}
finally
{
await host.StopAsync().ConfigureAwait(false);
}
return 0;
}
}

View File

@@ -0,0 +1,159 @@
using System.Diagnostics;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Explorer.Hosting;
using WinForms = System.Windows.Forms;
namespace Explorer.Host;
internal sealed class HostTray : IDisposable
{
private readonly Action _quit;
private Thread? _thread;
private WinForms.ApplicationContext? _context;
private NotifyIcon? _icon;
private bool _disposed;
public HostTray(Action quit) => _quit = quit;
public void Start()
{
_thread = new Thread(Run)
{
IsBackground = true,
Name = "Explorer.Host.Tray"
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
private void Run()
{
WinForms.Application.EnableVisualStyles();
WinForms.Application.SetCompatibleTextRenderingDefault(false);
var menu = new ContextMenuStrip();
menu.Items.Add("Open Explorer Workbench", null, (_, _) => OpenWorkbench());
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("Quit background host", null, (_, _) => _quit());
_icon = new NotifyIcon
{
Text = "Explorer Workbench host",
Icon = LoadIcon(),
Visible = true,
ContextMenuStrip = menu
};
_icon.MouseClick += (_, e) =>
{
if (e.Button == MouseButtons.Left)
{
OpenWorkbench();
}
};
_context = new WinForms.ApplicationContext();
try
{
WinForms.Application.Run(_context);
}
finally
{
HideIcon();
}
}
private static void OpenWorkbench()
{
var exe = HostLogonAutostart.FindAppExecutable();
if (exe is null)
{
return;
}
foreach (var process in Process.GetProcessesByName("Explorer.App"))
{
try
{
if (process.MainModule?.FileName is { } path
&& string.Equals(Path.GetFullPath(path), Path.GetFullPath(exe), StringComparison.OrdinalIgnoreCase)
&& process.MainWindowHandle != IntPtr.Zero)
{
ShowWindow(process.MainWindowHandle, 9);
SetForegroundWindow(process.MainWindowHandle);
return;
}
}
catch
{
// access denied on MainModule
}
}
Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true });
}
private static Icon LoadIcon()
{
var path = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(path))
{
try
{
var extracted = Icon.ExtractAssociatedIcon(path);
if (extracted is not null)
{
return extracted;
}
}
catch
{
// fall back
}
}
return SystemIcons.Application;
}
private void HideIcon()
{
if (_icon is null)
{
return;
}
_icon.Visible = false;
_icon.Dispose();
_icon = null;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
_context?.ExitThread();
}
catch
{
// already exited
}
if (_thread is { IsAlive: true } && !_thread.Join(TimeSpan.FromSeconds(2)))
{
HideIcon();
}
}
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
}

View File

@@ -1,25 +1,36 @@
using Explorer.Hosting;
using Explorer.Host;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
var env = new WindowsAppEnvironment();
void Boot(string message)
{
try
{
File.AppendAllText(
Path.Combine(env.LogDirectory, "host-boot.log"),
$"{DateTimeOffset.Now:o} {message}{Environment.NewLine}");
}
catch
{
// boot log must not prevent the host from starting
}
}
Boot("Main");
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(env.LogDirectory, "explorer-host-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14)
retainedFileCountLimit: 14,
shared: true)
.CreateLogger();
try
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddExplorerHostProcess();
using var host = builder.Build();
await host.RunAsync().ConfigureAwait(false);
return 0;
return await HostEntry.RunAsync(args, Boot).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already in use", StringComparison.OrdinalIgnoreCase))
{

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Explorer.Host"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,26 @@
<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.Abstractions" 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.Plugin.Abstractions\Explorer.Plugin.Abstractions.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" />
<InternalsVisibleTo Include="Explorer.Hosting.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,74 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public static class ExplorerHostClientServices
{
public static IServiceCollection AddExplorerClient(this IServiceCollection services, IWorkbenchHost workbench)
{
services.AddSingleton(workbench);
services.AddSingleton(workbench.Indexing);
services.AddSingleton(workbench.Transfers);
services.AddSingleton(workbench.Sources);
services.AddSingleton(workbench.Mutations);
services.AddSingleton(workbench as ICloudOverlay ?? NullCloudOverlay.Instance);
if (workbench is IHostConnection connection)
{
services.AddSingleton(connection);
}
services.AddExplorerClientRuntime();
return services;
}
public static IServiceCollection AddExplorerClientRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IClock, SystemClock>();
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
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, readOnly: true);
});
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton(sp => new SourceManager(
sp.GetRequiredService<IIndexStore>(),
sp.GetRequiredService<IVolumeService>(),
sp.GetRequiredService<IAppEnvironment>(),
sp.GetRequiredService<IClock>(),
sp.GetRequiredService<ILogger<SourceManager>>(),
sp.GetService<ISourceHost>()));
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<BrowseService>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<RenamePlanner>();
services.AddExplorerOperations();
services.AddHostedService<IndexStoreLifetime>();
return services;
}
}

View File

@@ -0,0 +1,99 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace Explorer.Hosting;
public static class HostLogonAutostart
{
public const string RunValueName = "ExplorerWorkbenchHost";
public const string TaskName = "ExplorerWorkbenchHost";
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
public static string? FindHostExecutable() => FindBesideProcess("Explorer.Host.exe");
public static string? FindAppExecutable() => FindBesideProcess("Explorer.App.exe");
private static string? FindBesideProcess(string fileName)
{
var dir = Path.GetDirectoryName(Environment.ProcessPath);
if (string.IsNullOrWhiteSpace(dir))
{
dir = AppContext.BaseDirectory;
}
var candidate = Path.Combine(dir, fileName);
return File.Exists(candidate) ? candidate : null;
}
public static string RunCommand(string hostExePath) => Quote(Path.GetFullPath(hostExePath));
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;
}
try
{
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
if (key is null)
{
error = "Could not open the current-user sign-in list.";
return false;
}
key.SetValue(RunValueName, RunCommand(hostExePath));
TryDeleteLegacyLogonTask();
error = "";
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public static bool TryUnregister(out string error)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true);
key?.DeleteValue(RunValueName, throwOnMissingValue: false);
TryDeleteLegacyLogonTask();
error = "";
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static string Quote(string path) => "\"" + path + "\"";
private static void TryDeleteLegacyLogonTask()
{
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "schtasks.exe",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
ArgumentList = { "/Delete", "/TN", TaskName, "/F" }
});
process?.WaitForExit(4000);
}
catch
{
// leftover Task Scheduler entry is optional
}
}
}

View File

@@ -1,3 +1,6 @@
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Explorer.Domain;
@@ -15,6 +18,10 @@ public static class WorkbenchIpc
public static string DefaultPipeName { get; } = Sanitize("ExplorerWorkbench-" + Environment.UserName);
public static Encoding Utf8 { get; } = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
public static PipeOptions StreamOptions { get; } = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly;
public static JsonSerializerOptions Json { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -28,9 +35,15 @@ public static class WorkbenchIpc
var chars = name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
return new string(chars);
}
public static bool IsListening(string pipeName, int timeoutMs = 50)
=> WaitNamedPipe(@"\\.\pipe\" + pipeName, (uint)Math.Max(1, timeoutMs));
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool WaitNamedPipe(string lpNamedPipeName, uint nTimeOut);
}
internal sealed class IpcEnvelope
public sealed class IpcEnvelope
{
public int V { get; set; } = WorkbenchIpc.ProtocolVersion;
public string? Id { get; set; }

View File

@@ -1,31 +1,36 @@
using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IAsyncDisposable
{
private readonly NamedPipeClientStream _pipe;
private readonly StreamWriter _writer;
private readonly StreamReader _reader;
private NamedPipeClientStream _pipe;
private StreamWriter _writer;
private StreamReader _reader;
private Task _readLoop;
private readonly WorkbenchIpcOptions _options;
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 readonly SourceProxy _sources;
private readonly MutationProxy _mutations;
private bool _disposed;
private bool _suppressRestart;
private WorkbenchPipeClient(NamedPipeClientStream pipe)
private WorkbenchPipeClient(NamedPipeClientStream pipe, WorkbenchIpcOptions options)
{
_options = options;
_pipe = pipe;
_writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
_writer = new StreamWriter(pipe, WorkbenchIpc.Utf8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(pipe, WorkbenchIpc.Utf8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
_indexing = new IndexingProxy(this);
_transfers = new TransferProxy(this);
_sources = new SourceProxy(this);
@@ -37,6 +42,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
public ITransferHost Transfers => _transfers;
public ISourceHost Sources => _sources;
public IIndexMutations Mutations => _mutations;
public bool IsConnected => !_disposed && _pipe.IsConnected;
public event EventHandler<string>? StatusChanged;
public static async Task<WorkbenchPipeClient> ConnectAsync(
WorkbenchIpcOptions options,
@@ -52,27 +59,53 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
".",
options.PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
WorkbenchIpc.StreamOptions);
WorkbenchPipeClient? client = null;
try
{
var remaining = deadline - DateTime.UtcNow;
if (remaining < TimeSpan.FromMilliseconds(50))
var remaining = (int)Math.Clamp((deadline - DateTime.UtcNow).TotalMilliseconds, 1, 400);
await ConnectOnceAsync(pipe, options.PipeName, remaining, cancellationToken).ConfigureAwait(false);
client = new WorkbenchPipeClient(pipe, options);
using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
pingCts.CancelAfter(TimeSpan.FromSeconds(3));
try
{
remaining = TimeSpan.FromMilliseconds(50);
await client.CallAsync("Ping", pingCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"Named pipe '{options.PipeName}' did not answer Ping.", ex);
}
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);
if (client is not null)
{
await client.DisposeAsync().ConfigureAwait(false);
}
else
{
await pipe.DisposeAsync().ConfigureAwait(false);
}
var delay = TimeSpan.FromMilliseconds(80);
var left = deadline - DateTime.UtcNow;
if (left <= TimeSpan.Zero)
{
break;
}
if (delay > left)
{
delay = left;
}
try
{
await Task.Delay(80, cancellationToken).ConfigureAwait(false);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -85,6 +118,95 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
$"Could not connect to Explorer Workbench host pipe '{options.PipeName}'.", last);
}
public IReadOnlyList<ProviderPlace> GetPlaces()
=> ReadPayload<ProviderPlace[]>(Call("Cloud.Places").Payload) ?? [];
public string? FindProviderId(string path)
=> Call("Cloud.FindProviderId", s: path).S;
public bool HasCapability(string path, ProviderCapability capability)
=> Call("Cloud.HasCapability", n: (long)capability, s: path).Flag == true;
public async Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
IReadOnlyList<FileSystemItem> items,
CancellationToken cancellationToken = default)
{
var reply = await CallAsync("Cloud.Enrich", cancellationToken, payload: Json(items)).ConfigureAwait(false);
return ReadPayload<FileSystemItem[]>(reply.Payload) ?? items;
}
public async Task<ProviderActionResult> InvokeAsync(
ProviderAction action,
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
{
var reply = await CallAsync(
"Cloud.Invoke",
cancellationToken,
n: (long)action,
paths: paths.ToArray())
.ConfigureAwait(false);
return ReadPayload<ProviderActionResult>(reply.Payload)
?? new ProviderActionResult(ProviderActionStatus.Failed, "Host did not return a result.");
}
public async Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default)
{
var reply = await CallAsync("Cloud.State", cancellationToken, s: path).ConfigureAwait(false);
return ReadPayload<ProviderItemState>(reply.Payload);
}
public async Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
{
var reply = await CallAsync("Cloud.Quota", cancellationToken, s: rootPath).ConfigureAwait(false);
return ReadPayload<ProviderQuota>(reply.Payload);
}
private static T? ReadPayload<T>(string? payload)
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json);
private static string Json<T>(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json);
private static async Task ConnectOnceAsync(
NamedPipeClientStream pipe,
string pipeName,
int timeoutMs,
CancellationToken cancellationToken)
{
var connect = Task.Run(() => pipe.Connect(Math.Max(1, timeoutMs)), CancellationToken.None);
try
{
await connect.WaitAsync(TimeSpan.FromMilliseconds(timeoutMs + 250), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is TimeoutException or IOException or ObjectDisposedException)
{
TryDispose(pipe);
throw new TimeoutException($"Named pipe '{pipeName}' is not listening.", ex);
}
catch (OperationCanceledException)
{
TryDispose(pipe);
try
{
await connect.WaitAsync(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false);
}
catch
{
// Connect(int) is still unwinding after we gave up.
}
throw;
}
}
private static void TryDispose(NamedPipeClientStream pipe)
{
try { pipe.Dispose(); }
catch (ObjectDisposedException) { }
catch (IOException) { }
}
internal IpcEnvelope Call(string op, long? n = null, string? s = null)
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
@@ -97,6 +219,27 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
string[]? paths = null,
bool? flag = null,
string? payload = null)
{
try
{
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload).ConfigureAwait(false);
}
catch (Exception ex) when (!_disposed && !_suppressRestart && ex is IOException or ObjectDisposedException)
{
await RecycleAsync(cancellationToken).ConfigureAwait(false);
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload).ConfigureAwait(false);
}
}
private async Task<IpcEnvelope> SendOnceAsync(
string op,
CancellationToken cancellationToken,
long? n,
string? s,
string? dest,
string[]? paths,
bool? flag,
string? payload)
{
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -148,6 +291,50 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
}
}
public async Task RequestShutdownAsync(CancellationToken cancellationToken = default)
{
_suppressRestart = true;
try
{
await CallAsync("Host.Shutdown", cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or TimeoutException)
{
// Host is already stopping.
}
StatusChanged?.Invoke(this, "Background host stopped");
}
private async Task RecycleAsync(CancellationToken cancellationToken)
{
if (_suppressRestart)
{
throw new IOException("Background host stopped.");
}
StatusChanged?.Invoke(this, "Host disconnected — reconnecting…");
if (!await WorkbenchHostConnector.EnsureHostAsync(TimeSpan.FromSeconds(30), cancellationToken: cancellationToken)
.ConfigureAwait(false))
{
throw new IOException("Explorer.Host.exe is not reachable.");
}
var pipe = new NamedPipeClientStream(".", _options.PipeName, PipeDirection.InOut, WorkbenchIpc.StreamOptions);
await ConnectOnceAsync(pipe, _options.PipeName, 2000, cancellationToken).ConfigureAwait(false);
var oldWriter = _writer;
var oldReader = _reader;
var oldPipe = _pipe;
_pipe = pipe;
_writer = new StreamWriter(pipe, WorkbenchIpc.Utf8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(pipe, WorkbenchIpc.Utf8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
_readLoop = ReadLoopAsync(_cts.Token);
try { await oldWriter.DisposeAsync().ConfigureAwait(false); } catch { /* old session */ }
try { oldReader.Dispose(); } catch { /* old session */ }
try { await oldPipe.DisposeAsync().ConfigureAwait(false); } catch { /* old session */ }
StatusChanged?.Invoke(this, "Ready · background host connected");
}
internal void RaiseProgress(ScanProgress progress) => _indexing.Raise(progress);
internal void RaiseChanged() => _transfers.RaiseChanged();
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
@@ -197,6 +384,10 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
case "Transfers.JobFinished" when envelope.Job is not null:
RaiseFinished(envelope.Job);
break;
case "Host.Stopping":
_suppressRestart = true;
StatusChanged?.Invoke(this, "Background host stopped");
break;
}
continue;
@@ -212,6 +403,13 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
{
// shutting down
}
catch (IOException)
{
if (!_disposed && !_suppressRestart)
{
StatusChanged?.Invoke(this, "Host disconnected — reconnecting…");
}
}
finally
{
foreach (var tcs in _pending.Values)
@@ -223,30 +421,17 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
public async ValueTask DisposeAsync()
{
_disposed = true;
await _cts.CancelAsync().ConfigureAwait(false);
try
{
await _writer.DisposeAsync().ConfigureAwait(false);
}
catch
{
// pipe already closed
}
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
}
catch (TimeoutException) { }
catch (OperationCanceledException) { }
_cts.Dispose();
_send.Dispose();
@@ -256,9 +441,7 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
{
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);
@@ -273,14 +456,9 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
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 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);
@@ -309,6 +487,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
=> _client.CallAsync("Transfers.EnqueueAddToArchive", cancellationToken, dest: archivePath, paths: sources.ToArray());
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath);
public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueConvert", cancellationToken, s: kind.ToString(), dest: destinationPath, paths: [sourcePath]);
public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
}
@@ -317,7 +497,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
{
private readonly WorkbenchPipeClient _client;
public SourceProxy(WorkbenchPipeClient client) => _client = client;
public Task RefreshAsync(CancellationToken cancellationToken = default)
=> _client.CallAsync("Sources.Refresh", cancellationToken);
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
@@ -327,7 +506,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
private async Task<Source?> CallSource(string op, string path, CancellationToken cancellationToken)
=> (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source;
}
@@ -336,7 +514,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
{
private readonly WorkbenchPipeClient _client;
public MutationProxy(WorkbenchPipeClient client) => _client = client;
public async Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Mutations.UpsertSyncProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0;
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default)
@@ -353,7 +530,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
=> _client.CallAsync("Mutations.EnqueueHashCollisions", cancellationToken, n: sourceId ?? 0);
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.UpsertRelation", cancellationToken, payload: Json(relation));
private static string Json<T>(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json);
}
}

View File

@@ -0,0 +1,130 @@
using System.Diagnostics;
using Explorer.Hosting.Ipc;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public static class WorkbenchHostConnector
{
public static async Task<WorkbenchPipeClient?> ConnectOrStartAsync(
TimeSpan timeout,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var options = new WorkbenchIpcOptions();
logger?.LogInformation("Connecting to named pipe {Pipe}", options.PipeName);
if (await EnsureHostAsync(timeout, logger, cancellationToken).ConfigureAwait(false))
{
try
{
return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(2), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
}
}
return null;
}
public static async Task<bool> EnsureHostAsync(
TimeSpan timeout,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var options = new WorkbenchIpcOptions();
if (WorkbenchIpc.IsListening(options.PipeName, 80))
{
return true;
}
var dbPath = new WindowsAppEnvironment().DatabasePath;
if (IndexStoreLock.IsHeld(dbPath))
{
logger?.LogWarning("Index is already open for write; waiting for the existing host pipe");
return await WaitForPipeAsync(options, timeout, logger, started: null, cancellationToken)
.ConfigureAwait(false);
}
var exe = HostLogonAutostart.FindHostExecutable();
if (exe is null)
{
logger?.LogWarning("Explorer.Host.exe is not beside the window");
return false;
}
Process? started = Process.GetProcessesByName("Explorer.Host")
.FirstOrDefault(p =>
{
try { return p.MainModule?.FileName is { } path && PathsEqual(path, exe); }
catch { return false; }
});
if (started is not null)
{
logger?.LogInformation("Explorer.Host.exe is already running ({Pid})", started.Id);
}
else
{
try
{
started = Process.Start(new ProcessStartInfo
{
FileName = exe,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(exe)
});
logger?.LogInformation("Started Explorer.Host.exe ({Pid})", started?.Id);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not start Explorer.Host.exe");
return false;
}
}
return await WaitForPipeAsync(options, timeout, logger, started, cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> WaitForPipeAsync(
WorkbenchIpcOptions options,
TimeSpan timeout,
ILogger? logger,
Process? started,
CancellationToken cancellationToken)
{
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
waitCts.CancelAfter(timeout);
while (!waitCts.IsCancellationRequested)
{
if (started is { HasExited: true })
{
logger?.LogWarning("Explorer.Host.exe exited with {Code} before the pipe was ready", started.ExitCode);
return false;
}
if (WorkbenchIpc.IsListening(options.PipeName, 80))
{
return true;
}
try
{
await Task.Delay(150, waitCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
break;
}
}
return false;
}
private static bool PathsEqual(string left, string right)
=> string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,17 @@
namespace Explorer.Hosting;
public sealed class DeferredServiceProvider : IServiceProvider
{
private IServiceProvider? _inner;
private readonly TaskCompletionSource<IServiceProvider> _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<IServiceProvider> Ready => _ready.Task;
public void Complete(IServiceProvider inner)
{
_inner = inner;
_ready.TrySetResult(inner);
}
public object? GetService(Type serviceType) => _inner?.GetService(serviceType);
}

View File

@@ -9,6 +9,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />

View File

@@ -4,7 +4,6 @@ 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;
@@ -24,13 +23,13 @@ public static class ExplorerHostServices
{
public static IServiceCollection AddExplorerCore(this IServiceCollection services)
{
services.AddExplorerShared(readOnly: false);
services.AddExplorerHostRuntime();
services.AddExplorerWorkers();
services.AddExplorerOperations();
return services;
}
public static IServiceCollection AddExplorerShared(this IServiceCollection services, bool readOnly)
public static IServiceCollection AddExplorerHostRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IClock, SystemClock>();
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
@@ -42,14 +41,16 @@ public static class ExplorerHostServices
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger, readOnly);
return new SqliteIndexStore(env.DatabasePath, logger, readOnly: false);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<ICloudOverlay>(sp => sp.GetRequiredService<StorageProviderRegistry>());
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
@@ -101,38 +102,11 @@ public static class ExplorerHostServices
return services;
}
public static IServiceCollection AddExplorerOperations(this IServiceCollection services)
{
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
return services;
}
public static IServiceCollection AddExplorerClient(this IServiceCollection services, IWorkbenchHost workbench)
{
services.AddSingleton(workbench);
services.AddSingleton(workbench.Indexing);
services.AddSingleton(workbench.Transfers);
services.AddSingleton(workbench.Sources);
services.AddSingleton(workbench.Mutations);
services.AddExplorerShared(readOnly: true);
services.AddExplorerOperations();
services.AddHostedService<IndexStoreLifetime>();
return services;
}
public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
{
services.TryAddSingleton<WorkbenchIpcOptions>();
services.TryAddSingleton<Explorer.Hosting.Ipc.WorkbenchIpcOptions>();
services.AddHostedService<IndexStoreLifetime>();
services.AddExplorerCore();
services.AddHostedService<WorkbenchPipeServer>();
return services;
}
}

View File

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

View File

@@ -1,25 +0,0 @@
using Explorer.Application;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting;
public sealed class IndexStoreLifetime : IHostedService
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
public IndexStoreLifetime(IIndexStore store, SourceManager sources)
{
_store = store;
_sources = sources;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
await _sources.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync();
}

View File

@@ -1,8 +1,11 @@
using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -10,49 +13,126 @@ namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeServer : BackgroundService
{
private readonly IWorkbenchHost _workbench;
private readonly IServiceProvider _services;
private readonly WorkbenchIpcOptions _options;
private readonly ILogger<WorkbenchPipeServer> _logger;
private readonly SemaphoreSlim _write = new(1, 1);
private readonly SemaphoreSlim _ensureWorkbench = new(1, 1);
private readonly TaskCompletionSource _listening = new(TaskCreationOptions.RunContinuationsAsynchronously);
private IWorkbenchHost? _workbench;
private ICloudOverlay? _overlay;
private Action? _shutdownRequested;
private int _shutdownOnce;
private readonly ConcurrentDictionary<StreamWriter, byte> _writers = new();
public WorkbenchPipeServer(
IWorkbenchHost workbench,
IServiceProvider services,
WorkbenchIpcOptions options,
ILogger<WorkbenchPipeServer> logger)
{
_workbench = workbench;
_services = services;
_options = options;
_logger = logger;
}
public Task Listening => _listening.Task;
public Action? ShutdownRequested
{
get => _shutdownRequested;
set => _shutdownRequested = value;
}
public void RequestShutdown() => _ = RequestShutdownAsync();
private IWorkbenchHost Workbench
=> _workbench ?? throw new InvalidOperationException("Workbench is not ready.");
private async Task EnsureWorkbenchAsync()
{
if (_workbench is not null)
{
return;
}
await _ensureWorkbench.WaitAsync().ConfigureAwait(false);
try
{
if (_workbench is not null)
{
return;
}
if (_services is DeferredServiceProvider deferred)
{
var inner = await deferred.Ready.ConfigureAwait(false);
_workbench = inner.GetRequiredService<IWorkbenchHost>();
return;
}
_workbench = _services.GetRequiredService<IWorkbenchHost>();
}
finally
{
_ensureWorkbench.Release();
}
}
private async Task EnsureOverlayAsync()
{
if (_overlay is not null)
{
return;
}
await _ensureWorkbench.WaitAsync().ConfigureAwait(false);
try
{
if (_overlay is not null)
{
return;
}
if (_services is DeferredServiceProvider deferred)
{
var inner = await deferred.Ready.ConfigureAwait(false);
_overlay = inner.GetRequiredService<ICloudOverlay>();
return;
}
_overlay = _services.GetRequiredService<ICloudOverlay>();
}
finally
{
_ensureWorkbench.Release();
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Listening on named pipe {Pipe} protocol v{Version}", _options.PipeName, WorkbenchIpc.ProtocolVersion);
var sessions = new List<Task>();
while (!stoppingToken.IsCancellationRequested)
{
try
{
sessions.RemoveAll(t => t.IsCompleted);
var server = new NamedPipeServerStream(
_options.PipeName,
PipeDirection.InOut,
1,
4,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await using (server.ConfigureAwait(false))
WorkbenchIpc.StreamOptions);
_listening.TrySetResult();
using var cancelPipe = stoppingToken.Register(() =>
{
_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);
}
try { server.Dispose(); }
catch (ObjectDisposedException) { }
catch (IOException) { }
});
await server.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
_logger.LogInformation("Window connected on named pipe {Pipe}", _options.PipeName);
sessions.Add(ServeSessionAsync(server, stoppingToken));
}
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
{
@@ -68,7 +148,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Workbench pipe session ended");
_logger.LogWarning(ex, "Workbench pipe accept failed");
try
{
await Task.Delay(250, stoppingToken).ConfigureAwait(false);
@@ -79,12 +159,37 @@ public sealed class WorkbenchPipeServer : BackgroundService
}
}
}
try
{
await Task.WhenAll(sessions).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Pipe session unwind");
}
}
private async Task ServeSessionAsync(NamedPipeServerStream server, CancellationToken stoppingToken)
{
try
{
await using (server.ConfigureAwait(false))
{
await ServeAsync(server, stoppingToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Workbench pipe session ended");
}
}
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" };
using var reader = new StreamReader(pipe, WorkbenchIpc.Utf8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
await using var writer = new StreamWriter(pipe, WorkbenchIpc.Utf8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
_writers.TryAdd(writer, 0);
void OnProgress(object? sender, ScanProgress progress)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Indexing.Progress", Progress = progress }, stoppingToken);
@@ -95,9 +200,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
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;
var hooked = false;
try
{
while (!stoppingToken.IsCancellationRequested)
@@ -126,15 +229,28 @@ public sealed class WorkbenchPipeServer : BackgroundService
continue;
}
if (!hooked && NeedsWorkbench(request.Op))
{
await EnsureWorkbenchAsync().ConfigureAwait(false);
Workbench.Indexing.ProgressChanged += OnProgress;
Workbench.Transfers.Changed += OnChanged;
Workbench.Transfers.JobFinished += OnFinished;
hooked = true;
}
var response = await HandleAsync(request).ConfigureAwait(false);
await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false);
}
}
finally
{
_workbench.Indexing.ProgressChanged -= OnProgress;
_workbench.Transfers.Changed -= OnChanged;
_workbench.Transfers.JobFinished -= OnFinished;
_writers.TryRemove(writer, out _);
if (hooked && _workbench is not null)
{
Workbench.Indexing.ProgressChanged -= OnProgress;
Workbench.Transfers.Changed -= OnChanged;
Workbench.Transfers.JobFinished -= OnFinished;
}
}
}
@@ -153,121 +269,172 @@ public sealed class WorkbenchPipeServer : BackgroundService
try
{
if (NeedsWorkbench(request.Op))
{
await EnsureWorkbenchAsync().ConfigureAwait(false);
}
if (request.Op?.StartsWith("Cloud.", StringComparison.Ordinal) == true)
{
await EnsureOverlayAsync().ConfigureAwait(false);
}
switch (request.Op)
{
case "Ping":
return reply;
case "Host.Shutdown":
await RequestShutdownAsync().ConfigureAwait(false);
return reply;
case "Indexing.EnqueueFullScan":
_workbench.Indexing.EnqueueFullScan(request.N ?? 0);
Workbench.Indexing.EnqueueFullScan(request.N ?? 0);
return reply;
case "Indexing.EnqueueFolderScan":
_workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
Workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.EnqueueReconcile":
_workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
Workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.Cancel":
_workbench.Indexing.Cancel(request.N ?? 0);
Workbench.Indexing.Cancel(request.N ?? 0);
return reply;
case "Transfers.Snapshot":
reply.Jobs = _workbench.Transfers.Snapshot().ToArray();
reply.Paused = _workbench.Transfers.IsPaused;
reply.Jobs = Workbench.Transfers.Snapshot().ToArray();
reply.Paused = Workbench.Transfers.IsPaused;
return reply;
case "Transfers.IsPaused":
reply.Paused = _workbench.Transfers.IsPaused;
reply.Paused = Workbench.Transfers.IsPaused;
return reply;
case "Transfers.PauseAll":
_workbench.Transfers.PauseAll();
Workbench.Transfers.PauseAll();
return reply;
case "Transfers.ResumeAll":
_workbench.Transfers.ResumeAll();
Workbench.Transfers.ResumeAll();
return reply;
case "Transfers.Pause":
_workbench.Transfers.Pause(request.N ?? 0);
Workbench.Transfers.Pause(request.N ?? 0);
return reply;
case "Transfers.Resume":
_workbench.Transfers.Resume(request.N ?? 0);
Workbench.Transfers.Resume(request.N ?? 0);
return reply;
case "Transfers.Retry":
_workbench.Transfers.Retry(request.N ?? 0);
Workbench.Transfers.Retry(request.N ?? 0);
return reply;
case "Transfers.Cancel":
_workbench.Transfers.Cancel(request.N ?? 0);
Workbench.Transfers.Cancel(request.N ?? 0);
return reply;
case "Transfers.Dismiss":
_workbench.Transfers.Dismiss(request.N ?? 0);
Workbench.Transfers.Dismiss(request.N ?? 0);
return reply;
case "Transfers.ClearFinished":
_workbench.Transfers.ClearFinished();
Workbench.Transfers.ClearFinished();
return reply;
case "Transfers.MoveUp":
reply.Flag = _workbench.Transfers.MoveUp(request.N ?? 0);
reply.Flag = Workbench.Transfers.MoveUp(request.N ?? 0);
return reply;
case "Transfers.MoveDown":
reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0);
reply.Flag = Workbench.Transfers.MoveDown(request.N ?? 0);
return reply;
case "Transfers.EnqueueCopy":
await _workbench.Transfers.EnqueueCopyAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
await Workbench.Transfers.EnqueueCopyAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueMove":
await _workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
await Workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueDelete":
await _workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
await Workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
return reply;
case "Transfers.EnqueueRename":
await _workbench.Transfers.EnqueueRenameAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
await Workbench.Transfers.EnqueueRenameAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueEmptyRecycleBin":
await _workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false);
await Workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false);
return reply;
case "Transfers.EnqueueExtract":
await _workbench.Transfers.EnqueueExtractAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
await Workbench.Transfers.EnqueueExtractAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueCompress":
await _workbench.Transfers.EnqueueCompressAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
await Workbench.Transfers.EnqueueCompressAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueAddToArchive":
await _workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false);
await Workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false);
return reply;
case "Transfers.EnqueueVerifyArchive":
await _workbench.Transfers.EnqueueVerifyArchiveAsync(request.S ?? "").ConfigureAwait(false);
await Workbench.Transfers.EnqueueVerifyArchiveAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueConvert":
var convertKind = Enum.TryParse<ConversionKind>(request.S, true, out var kind)
? kind
: ConversionFormats.Infer(request.Paths is { Length: > 0 } p ? p[0] : "", request.Dest ?? "");
var convertSource = request.Paths is { Length: > 0 } paths ? paths[0] : "";
await Workbench.Transfers.EnqueueConvertAsync(convertSource, request.Dest ?? "", convertKind).ConfigureAwait(false);
return reply;
case "Sources.Refresh":
await _workbench.Sources.RefreshAsync().ConfigureAwait(false);
await Workbench.Sources.RefreshAsync().ConfigureAwait(false);
return reply;
case "Sources.AddUnc":
reply.Source = await _workbench.Sources.AddUncAsync(request.S ?? "").ConfigureAwait(false);
reply.Source = await Workbench.Sources.AddUncAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Sources.EnsureForPath":
reply.Source = await _workbench.Sources.EnsureForPathAsync(request.S ?? "").ConfigureAwait(false);
reply.Source = await Workbench.Sources.EnsureForPathAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Sources.Forget":
reply.Flag = await _workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
reply.Flag = await Workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Mutations.UpsertSyncProfile":
reply.N = await _workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
reply.N = await Workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
return reply;
case "Mutations.DeleteSyncProfile":
await _workbench.Mutations.DeleteSyncProfileAsync(request.N ?? 0).ConfigureAwait(false);
await Workbench.Mutations.DeleteSyncProfileAsync(request.N ?? 0).ConfigureAwait(false);
return reply;
case "Mutations.UpsertOperationProfile":
reply.N = await _workbench.Mutations.UpsertOperationProfileAsync(Read<OperationProfile>(request.Payload)).ConfigureAwait(false);
reply.N = await Workbench.Mutations.UpsertOperationProfileAsync(Read<OperationProfile>(request.Payload)).ConfigureAwait(false);
return reply;
case "Mutations.DeleteOperationProfile":
await _workbench.Mutations.DeleteOperationProfileAsync(request.N ?? 0).ConfigureAwait(false);
await Workbench.Mutations.DeleteOperationProfileAsync(request.N ?? 0).ConfigureAwait(false);
return reply;
case "Mutations.CreateRenameBatch":
reply.N = await _workbench.Mutations.CreateRenameBatchAsync(Read<RenameBatchItem[]>(request.Payload) ?? []).ConfigureAwait(false);
reply.N = await Workbench.Mutations.CreateRenameBatchAsync(Read<RenameBatchItem[]>(request.Payload) ?? []).ConfigureAwait(false);
return reply;
case "Mutations.MarkRenameBatchUndone":
await _workbench.Mutations.MarkRenameBatchUndoneAsync(request.N ?? 0).ConfigureAwait(false);
await Workbench.Mutations.MarkRenameBatchUndoneAsync(request.N ?? 0).ConfigureAwait(false);
return reply;
case "Mutations.EnqueueHashCollisions":
await _workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
await Workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
return reply;
case "Mutations.UpsertRelation":
await _workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);
await Workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);
return reply;
case "Cloud.Places":
reply.Payload = JsonSerializer.Serialize(_overlay!.GetPlaces(), WorkbenchIpc.Json);
return reply;
case "Cloud.FindProviderId":
reply.S = _overlay!.FindProviderId(request.S ?? "");
return reply;
case "Cloud.HasCapability":
reply.Flag = _overlay!.HasCapability(request.S ?? "", (ProviderCapability)(request.N ?? 0));
return reply;
case "Cloud.Enrich":
reply.Payload = JsonSerializer.Serialize(
await _overlay!.EnrichAsync(Read<FileSystemItem[]>(request.Payload) ?? [], CancellationToken.None)
.ConfigureAwait(false),
WorkbenchIpc.Json);
return reply;
case "Cloud.Invoke":
reply.Payload = JsonSerializer.Serialize(
await _overlay!.InvokeAsync((ProviderAction)(request.N ?? 0), request.Paths ?? [], CancellationToken.None)
.ConfigureAwait(false),
WorkbenchIpc.Json);
return reply;
case "Cloud.State":
reply.Payload = JsonSerializer.Serialize(
await _overlay!.GetStateAsync(request.S ?? "", CancellationToken.None).ConfigureAwait(false),
WorkbenchIpc.Json);
return reply;
case "Cloud.Quota":
reply.Payload = JsonSerializer.Serialize(
await _overlay!.TryGetQuotaAsync(request.S ?? "", CancellationToken.None).ConfigureAwait(false),
WorkbenchIpc.Json);
return reply;
default:
reply.Ok = false;
@@ -283,6 +450,40 @@ public sealed class WorkbenchPipeServer : BackgroundService
}
}
private static bool NeedsWorkbench(string? op)
=> op is not null and not "Ping" and not "Host.Shutdown"
&& !op.StartsWith("Cloud.", StringComparison.Ordinal);
internal async Task RequestShutdownAsync()
{
if (Interlocked.Exchange(ref _shutdownOnce, 1) != 0)
{
return;
}
var envelope = new IpcEnvelope { Evt = "Host.Stopping" };
foreach (var writer in _writers.Keys)
{
try
{
await WriteAsync(writer, envelope, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// session already gone
}
}
try
{
ShutdownRequested?.Invoke();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Host shutdown callback");
}
}
private static T Read<T>(string? payload)
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);

View File

@@ -1,58 +0,0 @@
using System.Diagnostics;
using Explorer.Hosting.Ipc;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public static class WorkbenchHostConnector
{
public static async Task<WorkbenchPipeClient?> ConnectOrStartAsync(
TimeSpan timeout,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var options = new WorkbenchIpcOptions();
try
{
return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(1), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogDebug(ex, "Background host was not listening yet");
}
var exe = HostLogonAutostart.FindHostExecutable();
if (exe is null)
{
logger?.LogInformation("Explorer.Host.exe is not beside the window; using in-process core");
return null;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = exe,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(exe)
});
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not start Explorer.Host.exe");
return null;
}
try
{
return await WorkbenchPipeClient.ConnectAsync(options, timeout, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
return null;
}
}
}

View File

@@ -5,7 +5,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="SharpCompress" Version="0.50.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />

View File

@@ -0,0 +1,108 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ConvertViewModel : ObservableObject
{
private readonly ConversionPlanner _planner;
private readonly FileOperationService _ops;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly IMediaConversionProvider _conversion;
private readonly IReadOnlyList<string> _sources;
private OperationPlan? _plan;
[ObservableProperty] private ConversionKind _kind = ConversionKind.VideoToMp4;
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _canQueue;
public ConvertViewModel(
IReadOnlyList<string> sources,
string destPath,
ConversionKind kind,
ConversionPlanner planner,
FileOperationService ops,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration,
IMediaConversionProvider conversion)
{
_sources = sources;
_planner = planner;
_ops = ops;
_enumerator = enumerator;
_hydration = hydration;
_conversion = conversion;
Rows = [];
Kinds =
[
new ConversionKindOption(ConversionFormats.Label(ConversionKind.VideoToMp4), ConversionKind.VideoToMp4),
new ConversionKindOption(ConversionFormats.Label(ConversionKind.ExtractAudio), ConversionKind.ExtractAudio),
new ConversionKindOption(ConversionFormats.Label(ConversionKind.HeicToJpeg), ConversionKind.HeicToJpeg)
];
Kind = kind;
DestPath = destPath;
Rebuild();
}
public ObservableCollection<ProfilePreviewRow> Rows { get; }
public IReadOnlyList<ConversionKindOption> Kinds { get; }
public event EventHandler? CloseRequested;
partial void OnKindChanged(ConversionKind value) => Rebuild();
partial void OnDestPathChanged(string value) => Rebuild();
[RelayCommand]
public async Task QueueAsync()
{
var plan = Preview();
if (!plan.CanEnqueue)
{
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to convert.";
return;
}
await _ops.ConvertAsync(plan.Operations).ConfigureAwait(true);
CloseRequested?.Invoke(this, EventArgs.Empty);
}
private void Rebuild()
{
_plan = Preview();
Rows.Clear();
foreach (var row in _plan.ProfilePreview)
{
Rows.Add(row);
}
CanQueue = _plan.CanEnqueue;
var errors = _plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = _plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
Status = errors > 0
? _plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: _plan.Operations.Count == 0
? "Nothing to convert."
: $"{_plan.Operations.Count} will be queued"
+ (warnings > 0 ? $" · {warnings} skipped" : "");
}
private OperationPlan Preview()
=> _planner.Build(
_sources,
DestPath.Trim(),
Kind,
_enumerator,
_conversion.IsAvailable,
_conversion.MissingHint,
RenameBatchService.PathExists,
item => _hydration.WouldHydrateOnRead(item));
}
public sealed record ConversionKindOption(string Label, ConversionKind Kind);

View File

@@ -74,5 +74,32 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
}
}
public Task OpenInitialAsync() => Left.NavigateAsync("This PC");
public Task OpenInitialAsync() => Left.NavigateAsync(LocationRoots.ThisPc);
public SessionTabState Capture()
=> new(
string.IsNullOrWhiteSpace(Left.CurrentPath) ? LocationRoots.ThisPc : Left.CurrentPath,
string.IsNullOrWhiteSpace(Right.CurrentPath) ? null : Right.CurrentPath,
IsSplit,
SplitRatio,
ActivePane == Right);
public async Task RestoreAsync(SessionTabState state)
{
SetSplitRatio(state.SplitRatio);
var left = string.IsNullOrWhiteSpace(state.LeftPath) ? LocationRoots.ThisPc : state.LeftPath;
await Left.NavigateAsync(left).ConfigureAwait(true);
if (state.IsSplit)
{
IsSplit = true;
var right = string.IsNullOrWhiteSpace(state.RightPath) ? left : state.RightPath;
await Right.NavigateAsync(right).ConfigureAwait(true);
Activate(state.ActiveIsRight ? Right : Left);
}
else
{
IsSplit = false;
Activate(Left);
}
}
}

View File

@@ -19,7 +19,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly RenamePlanner _renamePlanner;
@@ -31,7 +31,12 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IGitCommandProvider _gitCommands;
private readonly IWorkspaceLauncher _workspace;
private readonly IHydrationGuard _hydration;
private readonly ConversionPlanner _conversionPlanner;
private readonly IFileSystemEnumerator _enumerator;
private readonly IMediaConversionProvider _conversion;
private readonly IThumbnailService? _thumbnails;
private readonly IHostConnection? _host;
private bool _hostStopped;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -51,6 +56,7 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _canUndoRenameBatch;
[ObservableProperty] private bool _showExtractArchive;
[ObservableProperty] private bool _showCompress;
[ObservableProperty] private bool _showConvert;
[ObservableProperty] private bool _showAddToArchive;
[ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal;
@@ -70,7 +76,7 @@ public sealed partial class MainViewModel : ObservableObject
IWorkbenchHost workbench,
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
ICloudOverlay providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences,
IVolumeService volumes,
@@ -83,7 +89,11 @@ public sealed partial class MainViewModel : ObservableObject
IWorkspaceLauncher workspace,
IGitCommandProvider gitCommands,
IHydrationGuard hydration,
IThumbnailService? thumbnails = null)
ConversionPlanner conversionPlanner,
IFileSystemEnumerator enumerator,
IMediaConversionProvider conversion,
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null)
{
_browse = browse;
_ops = ops;
@@ -102,7 +112,26 @@ public sealed partial class MainViewModel : ObservableObject
_gitCommands = gitCommands;
_workspace = workspace;
_hydration = hydration;
_conversionPlanner = conversionPlanner;
_enumerator = enumerator;
_conversion = conversion;
_thumbnails = thumbnails;
_host = hostConnection;
if (_host is not null)
{
_host.StatusChanged += (_, status) =>
{
void Apply() => Footer = status;
if (_ui is null)
{
Apply();
}
else
{
_ui.Post(_ => Apply(), null);
}
};
}
var prefs = preferences.Load();
Theme = prefs.Theme;
PathHistory = [];
@@ -168,8 +197,7 @@ public sealed partial class MainViewModel : ObservableObject
return;
}
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
var tab = CreateTab();
Tabs.Add(tab);
ActiveTab = tab;
PathText = tab.ActivePane.CurrentPath;
@@ -187,17 +215,32 @@ public sealed partial class MainViewModel : ObservableObject
PathHistory.Add(path);
}
await ActiveTab.OpenInitialAsync().ConfigureAwait(true);
await RestoreSessionAsync().ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
Footer = "Ready";
Footer = "Ready · background host connected";
}
public bool CanStopBackgroundHost => _host is not null && !_hostStopped;
[RelayCommand(CanExecute = nameof(CanStopBackgroundHost))]
public async Task StopBackgroundHostAsync()
{
if (_host is null)
{
return;
}
await _host.RequestShutdownAsync().ConfigureAwait(true);
_hostStopped = true;
Footer = "Background host stopped";
StopBackgroundHostCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
var tab = CreateTab();
Tabs.Add(tab);
ActiveTab = tab;
await tab.OpenInitialAsync().ConfigureAwait(true);
@@ -479,6 +522,38 @@ public sealed partial class MainViewModel : ObservableObject
public OperationProfilesViewModel CreateOperationProfilesViewModel()
=> new(_operationProfiles);
public ConvertViewModel? CreateConvertViewModel()
{
var items = RealSelected();
if (items.Count == 0)
{
Footer = "Select files or a folder to convert.";
return null;
}
var dest = ActivePane.CurrentPath;
if (string.IsNullOrWhiteSpace(dest) || LocationRoots.IsVirtual(dest))
{
dest = PathRules.Parent(items[0].FullPath);
}
if (string.IsNullOrWhiteSpace(dest) || LocationRoots.IsVirtual(dest))
{
Footer = "Choose a folder to convert to.";
return null;
}
return new ConvertViewModel(
items.Select(i => i.FullPath).ToList(),
dest,
ConversionFormats.Preferred(items.Select(i => i.Item.Name)),
_conversionPlanner,
_ops,
_enumerator,
_hydration,
_conversion);
}
public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath());
@@ -667,6 +742,7 @@ public sealed partial class MainViewModel : ObservableObject
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
ShowExtractArchive = real.Count > 0 && real.All(i => !i.IsDirectory && ArchiveFormats.IsArchive(i.Item.Name));
ShowCompress = real.Count > 0;
ShowConvert = real.Any(i => i.IsDirectory || ConversionFormats.IsConvertible(i.Item.Name));
ShowAddToArchive = real.Any(i => i.IsDirectory || !ArchiveFormats.IsArchive(i.Item.Name));
ShowVerifyArchive = ShowExtractArchive;
var target = WorkspaceDirectory();
@@ -984,7 +1060,7 @@ public sealed partial class MainViewModel : ObservableObject
var trimmed = path.Trim().TrimEnd('\\');
var id = providerId
?? _providers.Find(trimmed)?.Manifest.Id
?? _providers.FindProviderId(trimmed)
?? GuessCloudProvider(trimmed);
var name = string.IsNullOrWhiteSpace(displayName) ? CloudProviderLabel(id) : displayName;
_cloudPlaces.Add(id, trimmed, name);
@@ -1045,6 +1121,8 @@ public sealed partial class MainViewModel : ObservableObject
public void SaveLayout(double width, double height, double left, double top, bool maximized, double treeWidth)
{
var stored = _preferences.Load();
var tabs = Tabs.Select(tab => tab.Capture()).ToList();
var active = Math.Max(0, Tabs.IndexOf(ActiveTab));
_preferences.Save(stored with
{
Theme = UiPreferencesStore.NormalizeTheme(Theme),
@@ -1053,10 +1131,46 @@ public sealed partial class MainViewModel : ObservableObject
WindowLeft = left,
WindowTop = top,
WindowMaximized = maximized,
TreeWidth = treeWidth
TreeWidth = treeWidth,
SessionTabs = tabs,
SessionActiveTab = active
});
}
private ExplorerTabViewModel CreateTab()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
return tab;
}
private async Task RestoreSessionAsync()
{
var prefs = _preferences.Load();
var session = prefs.SessionTabs;
if (session is null || session.Count == 0)
{
await ActiveTab.OpenInitialAsync().ConfigureAwait(true);
return;
}
var activeIndex = Math.Clamp(prefs.SessionActiveTab, 0, session.Count - 1);
Tabs.Clear();
ExplorerTabViewModel? active = null;
for (var i = 0; i < session.Count; i++)
{
var tab = CreateTab();
await tab.RestoreAsync(session[i]).ConfigureAwait(true);
Tabs.Add(tab);
if (i == activeIndex)
{
active = tab;
}
}
ActiveTab = active ?? Tabs[0];
}
public async Task ApplyPreferencesAsync(UiPreferences preferences)
{
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) };

View File

@@ -30,14 +30,14 @@ public sealed class NavigationTreeViewModel
{
private readonly SourceManager _sources;
private readonly BrowseService _browse;
private readonly StorageProviderRegistry _providers;
private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
public NavigationTreeViewModel(
SourceManager sources,
BrowseService browse,
StorageProviderRegistry providers,
ICloudOverlay providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
{

View File

@@ -19,6 +19,8 @@ public sealed partial class OperationProfilesViewModel : ObservableObject
[ObservableProperty] private bool _requireGitClean;
[ObservableProperty] private bool _doCompress;
[ObservableProperty] private ArchiveFormat _archiveFormat = ArchiveFormat.SevenZip;
[ObservableProperty] private bool _doConvert;
[ObservableProperty] private ConversionKind _conversionKind = ConversionKind.VideoToMp4;
[ObservableProperty] private bool _doCopy = true;
[ObservableProperty] private bool _doRename;
[ObservableProperty] private string _renamePrefix = "";
@@ -40,13 +42,21 @@ public sealed partial class OperationProfilesViewModel : ObservableObject
new ArchiveFormatOption("7-Zip (.7z)", ArchiveFormat.SevenZip),
new ArchiveFormatOption("ZIP", ArchiveFormat.Zip)
];
ConversionKinds =
[
new ConversionKindOption(ConversionFormats.Label(ConversionKind.VideoToMp4), ConversionKind.VideoToMp4),
new ConversionKindOption(ConversionFormats.Label(ConversionKind.ExtractAudio), ConversionKind.ExtractAudio),
new ConversionKindOption(ConversionFormats.Label(ConversionKind.HeicToJpeg), ConversionKind.HeicToJpeg)
];
}
public ObservableCollection<OperationProfile> Profiles { get; }
public ObservableCollection<ProfilePreviewRow> Rows { get; }
public IReadOnlyList<ArchiveFormatOption> Formats { get; }
public bool AutoRunEnabled => DoCopy && !DoCompress && !HasRenameText;
public IReadOnlyList<ConversionKindOption> ConversionKinds { get; }
public bool AutoRunEnabled => DoCopy && !DoCompress && !DoConvert && !HasRenameText;
public bool CompressOptionsEnabled => DoCompress;
public bool ConvertOptionsEnabled => DoConvert;
public async Task LoadAsync()
{
@@ -94,6 +104,8 @@ public sealed partial class OperationProfilesViewModel : ObservableObject
RequireGitClean = value.RequireGitClean;
DoCompress = value.DoCompress;
ArchiveFormat = value.ArchiveFormat;
DoConvert = value.DoConvert;
ConversionKind = value.ConversionKind;
DoCopy = value.DoCopy;
DoRename = value.DoRename;
RenamePrefix = value.RenamePrefix;
@@ -112,6 +124,12 @@ public sealed partial class OperationProfilesViewModel : ObservableObject
RefreshAutoRun();
}
partial void OnDoConvertChanged(bool value)
{
OnPropertyChanged(nameof(ConvertOptionsEnabled));
RefreshAutoRun();
}
partial void OnDoRenameChanged(bool value) => RefreshAutoRun();
partial void OnRenamePrefixChanged(string value) => RefreshAutoRun();
partial void OnRenameSuffixChanged(string value) => RefreshAutoRun();
@@ -127,6 +145,8 @@ public sealed partial class OperationProfilesViewModel : ObservableObject
RequireGitClean = false;
DoCompress = false;
ArchiveFormat = ArchiveFormat.SevenZip;
DoConvert = false;
ConversionKind = ConversionKind.VideoToMp4;
DoCopy = true;
DoRename = false;
RenamePrefix = "";
@@ -234,6 +254,8 @@ public sealed partial class OperationProfilesViewModel : ObservableObject
RequireGitClean = RequireGitClean,
DoCompress = DoCompress,
ArchiveFormat = ArchiveFormat,
DoConvert = DoConvert,
ConversionKind = ConversionKind,
DoCopy = DoCopy,
DoRename = DoRename,
RenamePrefix = RenamePrefix ?? "",

View File

@@ -84,6 +84,11 @@ public sealed partial class TransferJobViewModel : ObservableObject
return FileName(job.DestinationPath);
}
if (job.Op == TransferOp.Convert)
{
return FileName(job.DestinationPath);
}
return FileName(job.SourcePath);
}
@@ -97,6 +102,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Compress => $"Compress to {FileName(job.DestinationPath)}",
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive",
TransferOp.Convert => $"Convert to {FileName(job.DestinationPath)}",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
@@ -179,6 +185,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working"
};
@@ -475,6 +482,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString()
};

View File

@@ -31,6 +31,27 @@ public sealed class IndexStoreLock : IDisposable
return @"Local\ExplorerWorkbench-Index-" + hash;
}
public static bool IsHeld(string databasePath)
{
var name = MutexNameFor(databasePath);
using var mutex = new Mutex(false, name);
try
{
if (!mutex.WaitOne(TimeSpan.Zero))
{
return true;
}
}
catch (AbandonedMutexException)
{
mutex.ReleaseMutex();
return false;
}
mutex.ReleaseMutex();
return false;
}
public static IndexStoreLock Acquire(string databasePath, TimeSpan? timeout = null)
{
var name = MutexNameFor(databasePath);

View File

@@ -33,19 +33,20 @@ internal sealed class OperationProfileStore : IOperationProfileStore
{
return await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO operation_profiles (name, source_path, dest_path, require_git_clean, do_compress,
archive_format, do_copy, do_rename, rename_prefix, rename_suffix, rename_search, rename_replace,
excludes, auto_run, source_volume_guid, dest_volume_guid, is_builtin, created_utc, last_run_utc,
last_status)
VALUES (@name, @src, @dst, @git, @compress, @fmt, @copy, @rename, @prefix, @suffix, @search, @replace,
@excludes, @auto, @sg, @dg, @builtin, @created, @run, @status);
archive_format, do_convert, convert_kind, do_copy, do_rename, rename_prefix, rename_suffix,
rename_search, rename_replace, excludes, auto_run, source_volume_guid, dest_volume_guid, is_builtin,
created_utc, last_run_utc, last_status)
VALUES (@name, @src, @dst, @git, @compress, @fmt, @convert, @kind, @copy, @rename, @prefix, @suffix,
@search, @replace, @excludes, @auto, @sg, @dg, @builtin, @created, @run, @status);
""", Args(profile)).ConfigureAwait(false);
}
await conn.ExecuteAsync("""
UPDATE operation_profiles SET name=@name, source_path=@src, dest_path=@dst, require_git_clean=@git,
do_compress=@compress, archive_format=@fmt, do_copy=@copy, do_rename=@rename, rename_prefix=@prefix,
rename_suffix=@suffix, rename_search=@search, rename_replace=@replace, excludes=@excludes, auto_run=@auto,
source_volume_guid=@sg, dest_volume_guid=@dg, last_run_utc=@run, last_status=@status
do_compress=@compress, archive_format=@fmt, do_convert=@convert, convert_kind=@kind, do_copy=@copy,
do_rename=@rename, rename_prefix=@prefix, rename_suffix=@suffix, rename_search=@search,
rename_replace=@replace, excludes=@excludes, auto_run=@auto, source_volume_guid=@sg,
dest_volume_guid=@dg, last_run_utc=@run, last_status=@status
WHERE id=@id
""", Args(profile)).ConfigureAwait(false);
return profile.Id;
@@ -63,6 +64,8 @@ internal sealed class OperationProfileStore : IOperationProfileStore
git = profile.RequireGitClean ? 1 : 0,
compress = profile.DoCompress ? 1 : 0,
fmt = profile.ArchiveFormat.ToString(),
convert = profile.DoConvert ? 1 : 0,
kind = profile.ConversionKind.ToString(),
copy = profile.DoCopy ? 1 : 0,
rename = profile.DoRename ? 1 : 0,
prefix = profile.RenamePrefix ?? "",
@@ -88,6 +91,8 @@ internal sealed class OperationProfileStore : IOperationProfileStore
RequireGitClean = row.require_git_clean != 0,
DoCompress = row.do_compress != 0,
ArchiveFormat = Enum.TryParse<ArchiveFormat>(row.archive_format, true, out var fmt) ? fmt : ArchiveFormat.SevenZip,
DoConvert = row.do_convert != 0,
ConversionKind = Enum.TryParse<ConversionKind>(row.convert_kind, true, out var kind) ? kind : ConversionKind.VideoToMp4,
DoCopy = row.do_copy != 0,
DoRename = row.do_rename != 0,
RenamePrefix = row.rename_prefix ?? "",
@@ -113,6 +118,8 @@ internal sealed class OperationProfileStore : IOperationProfileStore
public int require_git_clean { get; set; }
public int do_compress { get; set; }
public string archive_format { get; set; } = "";
public int do_convert { get; set; }
public string convert_kind { get; set; } = "";
public int do_copy { get; set; }
public int do_rename { get; set; }
public string rename_prefix { get; set; } = "";

View File

@@ -257,6 +257,8 @@ internal static class SchemaScript
require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip',
do_convert INTEGER NOT NULL DEFAULT 0,
convert_kind TEXT NOT NULL DEFAULT 'VideoToMp4',
do_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '',

View File

@@ -560,6 +560,8 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip',
do_convert INTEGER NOT NULL DEFAULT 0,
convert_kind TEXT NOT NULL DEFAULT 'VideoToMp4',
do_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '',
@@ -581,6 +583,15 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
SetUserVersion(conn, 8);
_logger.LogInformation("Migrated SQLite schema to v8 (operation profiles)");
version = 8;
}
if (version < 9)
{
EnsureColumn(conn, "operation_profiles", "do_convert", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "operation_profiles", "convert_kind", "TEXT NOT NULL DEFAULT 'VideoToMp4'");
SetUserVersion(conn, 9);
_logger.LogInformation("Migrated SQLite schema to v9 (conversion profiles)");
}
}

View File

@@ -0,0 +1,247 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Windows;
public sealed class FfmpegConversionExecutor : IMediaConversionProvider
{
private static readonly Regex Duration = new(@"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", RegexOptions.CultureInvariant);
private static readonly Regex OutTime = new(@"out_time(?:_ms|_us)?=(\d+)", RegexOptions.CultureInvariant);
private static readonly Regex OutClock = new(@"out_time=(\d+):(\d+):(\d+(?:\.\d+)?)", RegexOptions.CultureInvariant);
private static readonly Regex TimeEquals = new(@"time=(\d+):(\d+):(\d+(?:\.\d+)?)", RegexOptions.CultureInvariant);
private readonly Func<string?> _configuredPath;
public FfmpegConversionExecutor(UiPreferencesStore preferences)
=> _configuredPath = () => preferences.Load().FfmpegPath;
public bool IsAvailable => FfmpegLocator.Find(_configuredPath()) is not null;
public string MissingHint => FfmpegLocator.MissingHint;
public Task ConvertAsync(
string sourcePath,
string destinationPath,
ConversionKind kind,
IProgress<ConversionProgress>? progress,
CancellationToken cancellationToken)
{
var destDir = PathRules.Parent(destinationPath);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(PathRules.ToExtended(destDir));
}
var args = new List<string> { "-hide_banner", "-nostdin", "-y", "-i", sourcePath };
args.AddRange(KindArgs(kind));
args.Add("-progress");
args.Add("pipe:1");
args.Add(destinationPath);
return RunAsync(args, PathRules.Parent(sourcePath), destinationPath, sourcePath, progress, cancellationToken);
}
private static IEnumerable<string> KindArgs(ConversionKind kind)
=> kind switch
{
ConversionKind.ExtractAudio => ["-vn", "-c:a", "aac", "-b:a", "192k", "-map_metadata", "0"],
ConversionKind.HeicToJpeg => ["-frames:v", "1", "-q:v", "2", "-map_metadata", "0"],
_ =>
[
"-map", "0:v:0?", "-map", "0:a:0?",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", "-map_metadata", "0"
]
};
private async Task RunAsync(
IReadOnlyList<string> arguments,
string? workingDirectory,
string destinationPath,
string sourcePath,
IProgress<ConversionProgress>? progress,
CancellationToken cancellationToken)
{
var exe = FfmpegLocator.Find(_configuredPath())
?? throw new InvalidOperationException(MissingHint);
var psi = new ProcessStartInfo
{
FileName = exe,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
foreach (var argument in arguments)
{
psi.ArgumentList.Add(argument);
}
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
var errors = new StringBuilder();
var duration = TimeSpan.Zero;
process.OutputDataReceived += (_, e) =>
{
if (string.IsNullOrEmpty(e.Data))
{
return;
}
ReportProgress(e.Data, duration, destinationPath, progress);
};
process.ErrorDataReceived += (_, e) =>
{
if (string.IsNullOrEmpty(e.Data))
{
return;
}
errors.AppendLine(e.Data);
if (Duration.Match(e.Data) is { Success: true } match)
{
duration = ParseClock(match);
}
ReportProgress(e.Data, duration, destinationPath, progress);
};
if (!process.Start())
{
throw new IOException("FFmpeg could not be started.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await using var kill = cancellationToken.Register(() =>
{
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
});
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (process.ExitCode != 0)
{
TryDelete(destinationPath);
var detail = LastError(errors.ToString());
throw new IOException(string.IsNullOrEmpty(detail) ? $"FFmpeg failed ({process.ExitCode})." : detail);
}
progress?.Report(new ConversionProgress(100, destinationPath));
TryCopyTimestamp(sourcePath, destinationPath);
}
private static void ReportProgress(string line, TimeSpan duration, string destinationPath, IProgress<ConversionProgress>? progress)
{
if (progress is null)
{
return;
}
if (line.StartsWith("progress=end", StringComparison.Ordinal))
{
progress.Report(new ConversionProgress(100, destinationPath));
return;
}
var elapsed = TryParseElapsed(line);
if (elapsed is null)
{
return;
}
var percent = duration > TimeSpan.Zero
? (int)Math.Clamp(elapsed.Value.TotalMilliseconds / duration.TotalMilliseconds * 100, 0, 99)
: 0;
progress.Report(new ConversionProgress(percent, destinationPath));
}
private static TimeSpan? TryParseElapsed(string line)
{
var clock = OutClock.Match(line);
if (clock.Success)
{
return ParseClock(clock);
}
var time = TimeEquals.Match(line);
if (time.Success)
{
return ParseClock(time);
}
var ms = OutTime.Match(line);
if (ms.Success && long.TryParse(ms.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var raw))
{
// out_time_ms is microseconds on many builds; treat large values as µs.
return raw > 1_000_000_000
? TimeSpan.FromTicks(raw / 10)
: TimeSpan.FromMilliseconds(raw);
}
return null;
}
private static TimeSpan ParseClock(Match match)
{
var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
return TimeSpan.FromHours(hours) + TimeSpan.FromMinutes(minutes) + TimeSpan.FromSeconds(seconds);
}
private static string LastError(string stderr)
{
var lines = stderr.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for (var i = lines.Length - 1; i >= 0; i--)
{
var line = lines[i];
if (line.Contains("error", StringComparison.OrdinalIgnoreCase)
|| line.Contains("failed", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Unknown", StringComparison.OrdinalIgnoreCase))
{
return line;
}
}
return lines.Length == 0 ? "" : lines[^1];
}
private static void TryCopyTimestamp(string sourcePath, string destinationPath)
{
try
{
var src = PathRules.ToExtended(sourcePath);
var dst = PathRules.ToExtended(destinationPath);
if (File.Exists(src) && File.Exists(dst))
{
File.SetLastWriteTimeUtc(dst, File.GetLastWriteTimeUtc(src));
}
}
catch
{
// timestamps are convenience-only
}
}
private static void TryDelete(string path)
{
try
{
var target = PathRules.ToExtended(path);
if (File.Exists(target))
{
File.Delete(target);
}
}
catch
{
// leftover output is cleaned on retry
}
}
}