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;