Compare commits

...

3 Commits

Author SHA1 Message Date
6fc7506eb7 Add queued FFmpeg conversion and finish splitting the window from the host.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:49:50 +02:00
48d03f794f Cut over the window to Explorer.Host.exe so only the host writes the index.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 17:39:13 +02:00
7c23bc2474 Extract a per-user background host so indexing can later run outside the window.
Keep the GUI as the index writer for now; mutex, named pipe, and opt-in logon autostart prepare Explorer.Host.exe without two SQLite writers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 17:10:49 +02:00
98 changed files with 5320 additions and 277 deletions

View File

@@ -543,16 +543,16 @@ Potential provider:
Possible operations: Possible operations:
- [ ] Video conversion - [x] Video conversion
- [ ] Audio conversion - [ ] Audio conversion
- [ ] Codec conversion - [ ] Codec conversion
- [ ] Resolution conversion - [ ] Resolution conversion
- [ ] Extract audio - [x] Extract audio
- [ ] Generate thumbnails - [ ] Generate thumbnails
## Images ## Images
- [ ] HEIC -> JPEG - [x] HEIC -> JPEG
- [ ] PNG -> JPEG - [ ] PNG -> JPEG
- [ ] Resize - [ ] Resize
- [ ] Rotate - [ ] Rotate
@@ -566,6 +566,8 @@ Conversions should support:
and be usable inside File Operation Profiles. and be usable inside File Operation Profiles.
V1: Convert… dialog and an operation-profile Convert toggle. Jobs run on the host through the File Operations Queue. FFmpeg is discovered, not bundled.
--- ---
# Git Integration # Git Integration
@@ -693,9 +695,9 @@ Discovery: Settings path, then Program Files, then PATH. Missing 7-Zip fails the
## FFmpeg ## FFmpeg
Potential: `FfmpegConversionExecutor` (`IMediaConversionProvider`)
`MediaConversionProvider` Discovery: Settings path, then Program Files, then PATH. Missing FFmpeg fails the queued job with an install hint. FFmpeg is not bundled. V1: H.264 MP4, extract AAC/M4A, HEIC→JPEG. Preview first. Online-only cloud files are not hydrated.
## Git ## Git

View File

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

View File

@@ -837,17 +837,24 @@ Bitte diese Punkte klären. Empfohlene Defaults in Klammern:
--- ---
## Hintergrunddienst: A vs. B ## Hintergrundprozess: Explorer.Host.exe
| | A In-Process (V1) | B Windows Service (später) | Indexing, USN/watchers, transfer queue, hash worker, and history rollup run in **`Explorer.Host.exe`**, not in the WPF window.
| | Explorer.Host.exe (current) | Windows Service |
|---|---|---| |---|---|---|
| Komplexität | niedrig | Session 0, ACL, IPC, Updates | | Complexity | per-user process, named pipe | Session 0, ACL, service updates |
| Index wenn UI zu | stoppt | läuft weiter | | Index when the window is closed | continues | continues |
| USN-Rechte | oft unzureichend | SYSTEM kann Journal lesen | | Rights | same user as the window | typically SYSTEM |
| Crash-Isolation | UI-Crash stoppt Index | getrennt | | Crash isolation | window crash does not stop the index | isolated |
| Empfohlen | **V1 = A** | **V2 = B**, wenn Identity+Schema stabil sind | | Autostart | optional HKCU Run (current user, no elevation) | service start |
| Used | **yes** | **not used** |
V1 so schneiden, dass der Indexer ein `IHostedService` ist. Im Dienst-Host später derselbe Service, UI spricht über Named Pipe / gRPC (`Explorer.Contracts`). Nicht in V1 bauen, Interfaces nicht an WPF kleben. The window (`Explorer.App.exe`) opens the SQLite index **read-only** (WAL). Only the host opens it for write. `Explorer.Contracts` (`IWorkbenchHost`, `ICloudOverlay`, `IHostConnection`) is the IPC surface over a current-user named pipe. Plugin implementations load in the host; the window talks overlay through the pipe.
If the host is not running, the window starts `Explorer.Host.exe` beside itself. Closing the window does not stop the host. Quit it from the host tray (**Quit background host**) or **File → Stop background host…**. Settings can add the host to the current users Windows sign-in programs (`HKCU\...\Run`) so it starts at logon without administrator rights.
The original V1 sketch was in-process `IHostedService` inside the UI. That path is gone. A Windows Service is still out of scope.
--- ---
@@ -886,7 +893,7 @@ Deviations from the design above, with reasons:
3. **WPF-UI (lepoco)** — not used. Light/Dark Fluent-style brushes live in `Themes/Dark.xaml` and `Themes/Light.xaml` so the UI toolkit stays replaceable. 3. **WPF-UI (lepoco)** — not used. Light/Dark Fluent-style brushes live in `Themes/Dark.xaml` and `Themes/Light.xaml` so the UI toolkit stays replaceable.
4. **`PRAGMA mmap_size` / large `cache_size`** — not applied at runtime. They made SQLite native startup unreliable under concurrent test hosts; WAL + `synchronous=NORMAL` remain. 4. **`PRAGMA mmap_size` / large `cache_size`** — not applied at runtime. They made SQLite native startup unreliable under concurrent test hosts; WAL + `synchronous=NORMAL` remain.
5. **App data folder**`%LocalAppData%\ExplorerWorkbench` (not `Explorer`) so the working name does not collide with Windows Explorer. 5. **App data folder**`%LocalAppData%\ExplorerWorkbench` (not `Explorer`) so the working name does not collide with Windows Explorer.
6. **Background work** — in-process `IHostedService` instances (indexer, transfer queue, hash worker, history rollup, watchers). No Windows Service in this run. 6. **Background work** — indexer, transfer queue, hash worker, history rollup, and watchers run as `IHostedService` instances inside **`Explorer.Host.exe`**. The WPF window is a named-pipe client (`Explorer.Hosting.Client`) with a read-only SQLite store. No Windows Service; optional current-user sign-in (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`).
7. **Search syntax** — structured `SearchQuery` exists; Everything-like lexer is not shipped (Phase 7). 7. **Search syntax** — structured `SearchQuery` exists; Everything-like lexer is not shipped (Phase 7).
8. **UNIQUE identity**`UNIQUE (source_id, ifnull(parent_id,-1), name_norm)` because SQLite UNIQUE treats NULLs as distinct. 8. **UNIQUE identity**`UNIQUE (source_id, ifnull(parent_id,-1), name_norm)` because SQLite UNIQUE treats NULLs as distinct.
9. **INSERT ids**`Microsoft.Data.Sqlite` + Dapper `ExecuteScalarAsync` on `INSERT … RETURNING` leaves the write connection busy and hangs the next command. Writer-connection SQL uses `SqliteCommand` (`SqliteExec`) and `last_insert_rowid()`. 9. **INSERT ids**`Microsoft.Data.Sqlite` + Dapper `ExecuteScalarAsync` on `INSERT … RETURNING` leaves the write connection busy and hangs the next command. Writer-connection SQL uses `SqliteCommand` (`SqliteExec`) and `last_insert_rowid()`.

View File

@@ -8,7 +8,7 @@ Mental model:
Windows remains the source of truth. Removing a location from Workbench removes Workbenchs index data for it. It does **not** disconnect a network drive, unlink OneDrive, or change Explorer settings. Windows remains the source of truth. Removing a location from Workbench removes Workbenchs index data for it. It does **not** disconnect a network drive, unlink OneDrive, or change Explorer settings.
Version documented here: **0.1** (schema 8). This file is the user guide. Edit it in any text editor; Explorer Workbench reloads it when you open **Help → Documentation**. Version documented here: **0.1** (schema 9). This file is the user guide. Edit it in any text editor; Explorer Workbench reloads it when you open **Help → Documentation**.
--- ---
@@ -18,27 +18,27 @@ Workbench **does**:
- Browse live folders (local, removable, network, cloud mounts) - Browse live folders (local, removable, network, cloud mounts)
- Index locations you choose, then search and analyze them - Index locations you choose, then search and analyze them
- Queue copy, move, recycle, rename, archive, sync, and organize work - Queue copy, move, recycle, rename, archive, convert, sync, and organize work
- Overlay Git and cloud status without becoming a Git client or a sync engine - Overlay Git and cloud status without becoming a Git client or a sync engine
Workbench **does not**: Workbench **does not**:
- Two-way sync - Two-way sync
- Convert media (no FFmpeg in this build)
- Hydrate online-only cloud files just to look at them - Hydrate online-only cloud files just to look at them
- Change Windows drive mappings or cloud client folders - Change Windows drive mappings or cloud client folders
- Replace Git (no stash, branch UI, mergetool, or credential dialog) - Replace Git (no stash, branch UI, mergetool, or credential dialog)
Specialized tools still do specialized jobs. 7-Zip compresses. Git reports status, shows diffs, commits selected files, resolves conflicts, and runs fetch, pull, and push. Workbench orchestrates. Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a few media kinds. Git reports status, shows diffs, commits selected files, resolves conflicts, and runs fetch, pull, and push. Workbench orchestrates.
--- ---
## First launch ## First launch
The window opens immediately. Locations fill in a moment later — Workbench does not wait for slow network shares or a second instance locking the index. 1. The window starts `Explorer.Host.exe` if it is not already running, then connects over a named pipe. Locations fill in a moment later — Workbench does not wait for slow network shares.
2. Browsing works with an empty index. 2. Browsing works with an empty index.
3. Folder sizes, search, duplicates, and storage analysis need an index. Use the banner **Build index**, **Tools → Locations → Index this location**, or the toolbar **Index** control. 3. Folder sizes, search, duplicates, and storage analysis need an index. Use the banner **Build index**, **Tools → Locations → Index this location**, or the toolbar **Index** control.
4. Data lives under `%LocalAppData%\ExplorerWorkbench\` — never beside the executable. 4. Data lives under `%LocalAppData%\ExplorerWorkbench\` — never beside the executable.
5. Closing the window leaves the host running (indexing and the queue). A tray icon **Explorer Workbench host** can open the window again or **Quit background host**. **File → Stop background host…** does the same from the window.
| Path | Contents | | Path | Contents |
| --- | --- | | --- | --- |
@@ -51,7 +51,7 @@ The window opens immediately. Locations fill in a moment later — Workbench doe
## Window layout ## Window layout
- **Title bar** — Explorer Workbench; minimize / maximize / close. - **Title bar** — Explorer Workbench; minimize / maximize / close.
- **Menu** — File, View, Tools, Settings, Help. Tools is grouped: Storage, Locations, File Operations (including Archives), Automation, Development, Recycle Bin. - **Menu** — File (including Stop background host), View, Tools, Settings, Help. Tools is grouped: Storage, Locations, File Operations (including Archives and Convert), Automation, Development, Recycle Bin.
- **Toolbar** — path, navigation, view mode, search, storage, queue summary. - **Toolbar** — path, navigation, view mode, search, storage, queue summary.
- **Tree** — This PC, Network, Cloud (grouping is optional in Settings). - **Tree** — This PC, Network, Cloud (grouping is optional in Settings).
- **Folder pane** — details, list, or preview. Split pane is optional. - **Folder pane** — details, list, or preview. Split pane is optional.
@@ -67,6 +67,8 @@ The window opens immediately. Locations fill in a moment later — Workbench doe
| Details / List / Preview | View menu or toolbar | | Details / List / Preview | View menu or toolbar |
| Refresh | View → Refresh, or `F5` | | Refresh | View → Refresh, or `F5` |
Tabs, split panes, and the folder shown in each pane are restored the next time you open Workbench.
--- ---
## Locations ## Locations
@@ -172,7 +174,7 @@ Intentional and sync copies are hidden by default. Hardlinks are not wasted spac
Almost every change goes through the queue instead of happening silently. Almost every change goes through the queue instead of happening silently.
Supported operations today: copy, move, recycle, permanent delete, rename, empty Recycle Bin, extract, compress, add to archive, verify archive. Supported operations today: copy, move, recycle, permanent delete, rename, empty Recycle Bin, extract, compress, add to archive, verify archive, convert.
The queue: The queue:
@@ -217,6 +219,20 @@ Jobs go through the queue. Online-only cloud archives are refused so Workbench w
--- ---
## Convert
Needs **`ffmpeg.exe`** on the machine. FFmpeg is usually a zip, not an installer: unpack a Windows build and either put `ffmpeg.exe` on PATH, under `Program Files\ffmpeg\bin\`, or point Settings at the file. `ffprobe` / `ffplay` are not required. FFmpeg is not bundled. This is not HandBrake — a few conversions only.
| Kind | Output |
| --- | --- |
| Video to H.264 MP4 | `.mp4` next to the source, or in a folder you pick |
| Extract audio | AAC in `.m4a` |
| HEIC to JPEG | `.jpg` (depends on the FFmpeg build having a HEIC decoder) |
**Tools → File Operations → Convert…** or **Convert…** on the item context menu. Select files or a folder, pick a kind and destination, preview names, then Queue. Each file is one queue job on the background host. Online-only cloud files are skipped. Existing names get a unique suffix so nothing is overwritten. Source last-write time is copied onto the output when that is possible.
---
## Folder sync ## Folder sync
**Tools → Automation → Folder sync…****one-way** only. **Tools → Automation → Folder sync…****one-way** only.
@@ -236,18 +252,19 @@ Files copied by sync are tagged as synchronized duplicates.
**Tools → Automation → Operation profiles…** — named recipes that **plan** work, then enqueue it. Steps never touch the filesystem themselves. **Tools → Automation → Operation profiles…** — named recipes that **plan** work, then enqueue it. Steps never touch the filesystem themselves.
Toggles (not a free-form graph): Copy, Rename, Compress, require a clean Git working tree. Excludes are globs, one per line. Toggles (not a free-form graph): Copy, Rename, Compress, Convert, require a clean Git working tree. Excludes are globs, one per line.
Built-in recipes (seeded when the list is empty): Built-in recipes (seeded when the list is empty):
1. **Archive folder** — require clean Git, compress 7z, exclude `.git` / `bin` / `obj` / `.vs` 1. **Archive folder** — require clean Git, compress 7z, exclude `.git` / `bin` / `obj` / `.vs`
2. **Copy to destination** — copy; AutoRun when the destination volume connects 2. **Copy to destination** — copy; AutoRun when the destination volume connects
3. **Convert videos to MP4** — FFmpeg H.264 MP4 into the destination folder
Run from the window, from **Run profile** on the context menu (always previews first), or by dropping files onto a profile. AutoRun is Copy-only (no rename, no compress) and fires on unreachable → reachable, not on a timer while already online. Run from the window, from **Run profile** on the context menu (always previews first), or by dropping files onto a profile. AutoRun is Copy-only (no rename, no compress, no convert) and fires on unreachable → reachable, not on a timer while already online.
Dirty or missing Git, missing 7-Zip, or an unreachable destination stops the plan. Nothing is queued. Dirty or missing Git, missing 7-Zip, missing FFmpeg, or an unreachable destination stops the plan. Nothing is queued.
Not in this build: SHA-256, recycle source after success, FFmpeg, scheduled or folder-watcher triggers. Not in this build: SHA-256, recycle source after success, GPU tuner, trim editor, scheduled or folder-watcher triggers.
--- ---
@@ -295,7 +312,7 @@ When a cloud folder is added:
- **Always keep on this device** / **Free up space** when the provider supports pin/dehydrate - **Always keep on this device** / **Free up space** when the provider supports pin/dehydrate
- Quota in capacity/free space where the provider reports it - Quota in capacity/free space where the provider reports it
Workbench never starts a cloud vendors own two-way sync. It never hydrates a file as a side effect of browse, size, search, hash, or archive. Workbench never starts a cloud vendors own two-way sync. It never hydrates a file as a side effect of browse, size, search, hash, archive, or convert.
--- ---
@@ -309,8 +326,11 @@ Workbench never starts a cloud vendors own two-way sync. It never hydrates a
- Show protected system locations - Show protected system locations
- Auto-clear queue when done - Auto-clear queue when done
- Include archive contents in the index - Include archive contents in the index
- Automatically index removable drives when they appear
- Start Explorer.Host.exe at Windows sign-in (current-user Startup, no administrator rights)
- Path to 7-Zip - Path to 7-Zip
- Path to git.exe - Path to git.exe
- Path to ffmpeg.exe
These options change what Workbench shows and indexes. They do not change Windows Explorer settings. These options change what Workbench shows and indexes. They do not change Windows Explorer settings.
@@ -336,7 +356,7 @@ Left open on purpose:
- Two-way sync and conflict resolution UI - Two-way sync and conflict resolution UI
- Undo for copy/move - Undo for copy/move
- Concurrent copies across different disks - Concurrent copies across different disks
- FFmpeg / media conversion - GPU / trim / filter conversion
- Multi-PC search, sharing, encrypted vaults - Multi-PC search, sharing, encrypted vaults
- Robocopy as a second transfer engine - Robocopy as a second transfer engine
- Scheduled profiles and folder-watcher triggers - Scheduled profiles and folder-watcher triggers

View File

@@ -1,18 +1,26 @@
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls;
using Explorer.Hosting;
using Explorer.Hosting.Ipc;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog; using Serilog;
using Serilog.Extensions.Logging;
namespace Explorer.App; namespace Explorer.App;
public partial class App : System.Windows.Application public partial class App : System.Windows.Application
{ {
private IHost? _host; 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); base.OnStartup(e);
DispatcherUnhandledException += (_, args) => DispatcherUnhandledException += (_, args) =>
{ {
@@ -29,16 +37,59 @@ public partial class App : System.Windows.Application
retainedFileCountLimit: 14) retainedFileCountLimit: 14)
.CreateLogger(); .CreateLogger();
var splash = ShowStartupSplash();
_ = StartWorkbenchAsync(splash);
}
private async Task StartWorkbenchAsync(Window splash)
{
using var loggerFactory = new SerilogLoggerFactory(Log.Logger);
try
{
try
{
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() _host = Host.CreateDefaultBuilder()
.UseSerilog() .UseSerilog()
.ConfigureServices((_, services) => services.AddExplorer()) .ConfigureServices((_, services) =>
{
services.AddExplorerClient(_workbenchClient);
services.AddExplorerUi();
})
.Build(); .Build();
var vm = _host.Services.GetRequiredService<MainViewModel>(); var vm = _host.Services.GetRequiredService<MainViewModel>();
var window = _host.Services.GetRequiredService<MainWindow>(); var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi(); vm.PrepareUi();
window.DataContext = vm; window.DataContext = vm;
MainWindow = window;
window.Show(); window.Show();
ShutdownMode = ShutdownMode.OnMainWindowClose;
try try
{ {
await vm.InitializeAsync().ConfigureAwait(true); await vm.InitializeAsync().ConfigureAwait(true);
@@ -51,6 +102,16 @@ public partial class App : System.Windows.Application
await _host.StartAsync().ConfigureAwait(true); await _host.StartAsync().ConfigureAwait(true);
} }
catch (Exception ex)
{
Log.Error(ex, "Could not start Explorer Workbench");
Shutdown(-1);
}
finally
{
splash.Close();
}
}
protected override async void OnExit(ExitEventArgs e) protected override async void OnExit(ExitEventArgs e)
{ {
@@ -60,7 +121,35 @@ public partial class App : System.Windows.Application
_host.Dispose(); _host.Dispose();
} }
if (_workbenchClient is not null)
{
await _workbenchClient.DisposeAsync().ConfigureAwait(true);
}
Log.CloseAndFlush(); Log.CloseAndFlush();
base.OnExit(e); 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,127 +1,24 @@
using Explorer.Analysis;
using Explorer.Application; using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation; using Explorer.Presentation;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows; using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App; namespace Explorer.App;
public static class AppServices public static class AppServices
{ {
public static IServiceCollection AddExplorer(this IServiceCollection services) public static IServiceCollection AddExplorerUi(this IServiceCollection services)
{ {
services.AddSingleton<IClock, SystemClock>();
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IOsClipboard, Services.WpfClipboard>(); services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>(); services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<ThumbnailService>(); services.AddSingleton<ThumbnailService>();
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>()); services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>());
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenamePlanner>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<MainViewModel>(); services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService(sp => sp.GetRequiredService<ThumbnailService>()); services.AddHostedService(sp => sp.GetRequiredService<ThumbnailService>());
services.AddHostedService<WatcherHostedService>();
return services; return services;
} }
} }
public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}
}

View File

@@ -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,21 +22,26 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.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" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" /> <ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" /> <ProjectReference Include="..\Explorer.Host\Explorer.Host.csproj">
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" /> <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" /> <GlobalPropertiesToRemove>SelfContained;RuntimeIdentifier;PublishSingleFile</GlobalPropertiesToRemove>
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" /> </ProjectReference>
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" /> <ProjectReference Include="..\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.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" /> <ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="CopyExplorerHost" AfterTargets="Build">
<PropertyGroup>
<_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))</_HostDir>
</PropertyGroup>
<ItemGroup>
<_HostFiles Include="$(_HostDir)*.*" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
</ItemGroup>
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="'@(_HostFiles)' != ''" />
</Target>
</Project> </Project>

View File

@@ -47,6 +47,8 @@
<MenuItem Header="New _tab" InputGestureText="Ctrl+T" Command="{Binding NewTabCommand}"/> <MenuItem Header="New _tab" InputGestureText="Ctrl+T" Command="{Binding NewTabCommand}"/>
<MenuItem Header="_Split pane" Command="{Binding SplitCommand}"/> <MenuItem Header="_Split pane" Command="{Binding SplitCommand}"/>
<MenuItem Header="_Close tab" InputGestureText="Ctrl+W" Command="{Binding CloseTabCommand}" CommandParameter="{Binding ActiveTab}"/> <MenuItem Header="_Close tab" InputGestureText="Ctrl+W" Command="{Binding CloseTabCommand}" CommandParameter="{Binding ActiveTab}"/>
<Separator/>
<MenuItem Header="Stop _background host…" Click="OnStopBackgroundHost"/>
</MenuItem> </MenuItem>
<MenuItem Header="_View"> <MenuItem Header="_View">
<MenuItem Header="_Details" Command="{Binding SetViewCommand}" CommandParameter="Details"/> <MenuItem Header="_Details" Command="{Binding SetViewCommand}" CommandParameter="Details"/>
@@ -86,6 +88,8 @@
<MenuItem Header="_Verify archive" Click="OnVerifyArchive" <MenuItem Header="_Verify archive" Click="OnVerifyArchive"
IsEnabled="{Binding ShowVerifyArchive}"/> IsEnabled="{Binding ShowVerifyArchive}"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Convert…" Click="OnConvert"
IsEnabled="{Binding ShowConvert}"/>
<MenuItem Header="_Organize folder…" Click="OnOrganizeFolder"/> <MenuItem Header="_Organize folder…" Click="OnOrganizeFolder"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Automation"> <MenuItem Header="_Automation">
@@ -263,7 +267,7 @@
<TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center" Text="File operations queue"/> <TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center" Text="File operations queue"/>
</DockPanel> </DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="11" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" <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"/> TextWrapping="Wrap"/>
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}"> <ItemsControl ItemsSource="{Binding Transfers.Jobs}">
@@ -471,6 +475,8 @@
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/> Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to archive…" Click="OnAddToArchive" <MenuItem Header="Add to archive…" Click="OnAddToArchive"
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/> Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Convert…" Click="OnConvert"
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
<Separator/> <Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/> <MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/> <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) private async void OnVerifyArchive(object sender, RoutedEventArgs e)
=> await Vm.VerifySelectedAsync().ConfigureAwait(true); => 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) private async void OnFolderSync(object sender, RoutedEventArgs e)
{ {
var vm = Vm.CreateFolderSyncViewModel(); var vm = Vm.CreateFolderSyncViewModel();
@@ -1474,6 +1489,33 @@ public partial class MainWindow : Window
return null; 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) private async void OnOpenSettings(object sender, RoutedEventArgs e)
{ {
var dlg = new SettingsWindow(Vm) { Owner = this }; var dlg = new SettingsWindow(Vm) { Owner = this };

View File

@@ -82,11 +82,15 @@
<ComboBox ItemsSource="{Binding Formats}" DisplayMemberPath="Label" SelectedValuePath="Format" <ComboBox ItemsSource="{Binding Formats}" DisplayMemberPath="Label" SelectedValuePath="Format"
SelectedValue="{Binding ArchiveFormat}" Margin="0,0,0,8" SelectedValue="{Binding ArchiveFormat}" Margin="0,0,0,8"
IsEnabled="{Binding CompressOptionsEnabled}"/> 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="Copy to destination" IsChecked="{Binding DoCopy}" Margin="0,0,0,8"/>
<CheckBox Content="Run when the destination volume is connected" <CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/> IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12" <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"/> <TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True" <TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/> Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>

View File

@@ -61,6 +61,16 @@
Content="Include archive contents in the index"/> Content="Include archive contents in the index"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12" <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/> Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
<CheckBox x:Name="AutoIndexRemovable" Margin="0,0,0,6"
Content="Automatically index removable drives when they appear"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
<TextBlock Text="Background host" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
Content="Start Explorer.Host.exe at Windows sign-in"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="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 Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12" <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
@@ -77,6 +87,14 @@
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/> <Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/> <TextBox x:Name="GitPath"/>
</DockPanel> </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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>

View File

@@ -1,5 +1,6 @@
using System.Windows; using System.Windows;
using Explorer.Application; using Explorer.Application;
using Explorer.Hosting;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
namespace Explorer.App; namespace Explorer.App;
@@ -20,11 +21,14 @@ public partial class SettingsWindow : Window
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces; GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces; GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents; IndexArchives.IsChecked = prefs.IndexArchiveContents;
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
ShowHidden.IsChecked = prefs.ShowHiddenFiles; ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations; ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone; AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
SevenZipPath.Text = prefs.SevenZipPath ?? ""; SevenZipPath.Text = prefs.SevenZipPath ?? "";
GitPath.Text = prefs.GitPath ?? ""; GitPath.Text = prefs.GitPath ?? "";
FfmpegPath.Text = prefs.FfmpegPath ?? "";
} }
private void OnThemeChanged(object sender, RoutedEventArgs e) private void OnThemeChanged(object sender, RoutedEventArgs e)
@@ -45,17 +49,51 @@ public partial class SettingsWindow : Window
GroupNetworkPlaces = GroupNetwork.IsChecked == true, GroupNetworkPlaces = GroupNetwork.IsChecked == true,
GroupCloudPlaces = GroupCloud.IsChecked == true, GroupCloudPlaces = GroupCloud.IsChecked == true,
IndexArchiveContents = IndexArchives.IsChecked == true, IndexArchiveContents = IndexArchives.IsChecked == true,
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
ShowHiddenFiles = ShowHidden.IsChecked == true, ShowHiddenFiles = ShowHidden.IsChecked == true,
ShowProtectedSystemLocations = ShowProtected.IsChecked == true, ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true, AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(), 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); await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
DialogResult = true; DialogResult = true;
Close(); Close();
} }
private void ApplyBackgroundHostAutostart(bool enabled)
{
if (enabled)
{
var exe = HostLogonAutostart.FindHostExecutable();
if (exe is null)
{
MessageBox.Show(
this,
"Explorer.Host.exe was not found next to Explorer Workbench, so the sign-in task was not registered.",
"Background host",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
if (!HostLogonAutostart.TryRegister(exe, out var error))
{
MessageBox.Show(this, error, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
}
return;
}
if (!HostLogonAutostart.TryUnregister(out var unregisterError))
{
MessageBox.Show(this, unregisterError, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private void OnBrowseSevenZip(object sender, RoutedEventArgs e) private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{ {
var dlg = new Microsoft.Win32.OpenFileDialog var dlg = new Microsoft.Win32.OpenFileDialog
@@ -84,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) private void OnCancel(object sender, RoutedEventArgs e)
{ {
_vm.Theme = _originalTheme; _vm.Theme = _originalTheme;

View File

@@ -11,7 +11,7 @@ public sealed class BrowseService
private readonly IVolumeService _volumes; private readonly IVolumeService _volumes;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly SourceManager _sources; private readonly SourceManager _sources;
private readonly StorageProviderRegistry _providers; private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces; private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences; private readonly UiPreferencesStore _preferences;
private readonly IElevatedScanService? _elevation; private readonly IElevatedScanService? _elevation;
@@ -22,7 +22,7 @@ public sealed class BrowseService
IVolumeService volumes, IVolumeService volumes,
IIndexStore store, IIndexStore store,
SourceManager sources, SourceManager sources,
StorageProviderRegistry providers, ICloudOverlay providers,
CloudPlaceStore cloudPlaces, CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences, UiPreferencesStore preferences,
IElevatedScanService? elevation = null, IElevatedScanService? elevation = null,
@@ -375,7 +375,7 @@ public sealed class BrowseService
yield return new BrowseDelta { Path = path, Updated = accessUpdates }; 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) if (constrained)
{ {
await foreach (var enriched in EnrichInBatchesAsync(all, byPath, viewport, source?.Kind, constrained: true, cancellationToken) 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, bool compressAvailable,
string compressMissingHint, string compressMissingHint,
Func<string, bool>? pathExists = null, 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 issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>(); 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."); 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) 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() ?? ""; var destRoot = profile.DestPath?.Trim() ?? "";
if (needsDest) if (needsDest)
{ {
@@ -144,6 +146,44 @@ public sealed class FileOperationProfilePlanner
preview.Add(new ProfilePreviewRow("Compress", archive, $"{working.Count} item(s)")); 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) if (profile.DoCopy)
{ {
var copyDest = payload.ContainerName is null var copyDest = payload.ContainerName is null

View File

@@ -12,9 +12,9 @@ public interface IHydrationGuard
public sealed class HydrationGuard : 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) public bool WouldHydrateOnRead(FileSystemItem item)
{ {
@@ -38,7 +38,7 @@ public sealed class HydrationGuard : IHydrationGuard
public async Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default) 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) if (state is null)
{ {
return false; 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

@@ -1,5 +1,10 @@
namespace Explorer.Application; namespace Explorer.Application;
/// <summary>
/// Detects whether this process already has administrator rights.
/// Elevation is never requested automatically. A future helper may scan USN
/// under elevation; it must not host the transfer queue or index writer.
/// </summary>
public interface IElevatedScanService public interface IElevatedScanService
{ {
bool IsElevated { get; } bool IsElevated { get; }

View File

@@ -0,0 +1,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

@@ -0,0 +1,23 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class RemovableAutoIndexPlanner
{
public static IReadOnlyList<long> SourceIdsToScan(IEnumerable<Source> sources, bool autoIndexRemovable)
{
if (!autoIndexRemovable)
{
return [];
}
return sources
.Where(source =>
source.Kind == SourceKind.Removable
&& source.Status == SourceStatus.Online
&& !source.IsIndexed
&& !string.IsNullOrWhiteSpace(source.LastRootPath))
.Select(source => source.Id)
.ToList();
}
}

View File

@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -12,6 +13,7 @@ public sealed class SourceManager
private readonly IAppEnvironment _env; private readonly IAppEnvironment _env;
private readonly IClock _clock; private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger; private readonly ILogger<SourceManager> _logger;
private readonly ISourceHost? _remote;
private readonly object _refreshLock = new(); private readonly object _refreshLock = new();
private Task<IReadOnlyList<Source>>? _refreshInFlight; private Task<IReadOnlyList<Source>>? _refreshInFlight;
@@ -23,18 +25,31 @@ public sealed class SourceManager
IVolumeService volumes, IVolumeService volumes,
IAppEnvironment env, IAppEnvironment env,
IClock clock, IClock clock,
ILogger<SourceManager> logger) ILogger<SourceManager> logger,
ISourceHost? remote = null)
{ {
_store = store; _store = store;
_volumes = volumes; _volumes = volumes;
_env = env; _env = env;
_clock = clock; _clock = clock;
_logger = logger; _logger = logger;
_remote = remote;
} }
public async Task InitializeAsync(CancellationToken cancellationToken = default) public async Task InitializeAsync(CancellationToken cancellationToken = default)
{ {
await _store.OpenAsync(cancellationToken).ConfigureAwait(false); await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
if (!_store.CanWrite)
{
if (_remote is not null)
{
await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false);
}
InvalidateRefreshCache();
return;
}
await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false); await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false);
await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false); await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false);
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false); await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
@@ -70,6 +85,22 @@ public sealed class SourceManager
private async Task<IReadOnlyList<Source>> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken) private async Task<IReadOnlyList<Source>> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken)
{ {
if (!_store.CanWrite)
{
if (_remote is not null)
{
await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false);
}
var snapshot = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
lock (_refreshLock)
{
_refreshCacheTimestamp = Stopwatch.GetTimestamp();
}
return snapshot;
}
var started = Stopwatch.GetTimestamp(); var started = Stopwatch.GetTimestamp();
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList(); var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
var online = _volumes.EnumerateOnlineVolumes(); var online = _volumes.EnumerateOnlineVolumes();
@@ -201,6 +232,18 @@ public sealed class SourceManager
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default) public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
{ {
if (!_store.CanWrite)
{
if (_remote is null)
{
throw new InvalidOperationException("Cannot add a network location while the index is read-only.");
}
var added = await _remote.AddUncAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return added;
}
var root = PathRules.CanonicalUncRoot(path); var root = PathRules.CanonicalUncRoot(path);
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false); var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var fp = new VolumeFingerprint var fp = new VolumeFingerprint
@@ -269,6 +312,18 @@ public sealed class SourceManager
public async Task<bool> ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default) public async Task<bool> ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default)
{ {
if (!_store.CanWrite)
{
if (_remote is null)
{
return false;
}
var forgotten = await _remote.ForgetAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return forgotten;
}
var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false); var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false);
if (source is null || !CanForget(source)) if (source is null || !CanForget(source))
{ {
@@ -324,6 +379,18 @@ public sealed class SourceManager
return null; return null;
} }
if (!_store.CanWrite)
{
if (_remote is null)
{
return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
}
var ensured = await _remote.EnsureForPathAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return ensured;
}
var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false); var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (existing is not null) if (existing is not null)
{ {

View File

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

View File

@@ -1,7 +1,15 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
namespace Explorer.Application; 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( public sealed record UiPreferences(
string Theme, string Theme,
bool GroupNetworkPlaces, bool GroupNetworkPlaces,
@@ -18,13 +26,18 @@ public sealed record UiPreferences(
double? TreeWidth = null, double? TreeWidth = null,
string? SevenZipPath = null, string? SevenZipPath = null,
string? GitPath = null, string? GitPath = null,
string? FfmpegPath = null,
string? OrganizePictures = null, string? OrganizePictures = null,
string? OrganizeVideos = null, string? OrganizeVideos = null,
string? OrganizeAudio = null, string? OrganizeAudio = null,
string? OrganizeDocuments = null, string? OrganizeDocuments = null,
string? OrganizeInstallers = null, string? OrganizeInstallers = null,
string? OrganizeArchives = null, string? OrganizeArchives = null,
string? OrganizeDevelopment = null) string? OrganizeDevelopment = null,
bool AutoIndexRemovable = false,
bool BackgroundHostAtLogon = false,
IReadOnlyList<SessionTabState>? SessionTabs = null,
int SessionActiveTab = 0)
{ {
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false); public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
} }
@@ -69,10 +82,14 @@ public sealed class UiPreferencesStore
"show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"), "show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"), "show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"), "auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
"auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
.. SevenZipLines(preferences), .. SevenZipLines(preferences),
.. GitLines(preferences), .. GitLines(preferences),
.. FfmpegLines(preferences),
.. OrganizeLines(preferences), .. OrganizeLines(preferences),
.. LayoutLines(preferences) .. LayoutLines(preferences),
.. SessionLines(preferences)
]); ]);
} }
catch catch
@@ -90,8 +107,11 @@ public sealed class UiPreferencesStore
var showHidden = true; var showHidden = true;
var showProtected = false; var showProtected = false;
var autoClearQueue = false; var autoClearQueue = false;
var autoIndexRemovable = false;
var backgroundHostAtLogon = false;
string? sevenZipPath = null; string? sevenZipPath = null;
string? gitPath = null; string? gitPath = null;
string? ffmpegPath = null;
string? organizePictures = null; string? organizePictures = null;
string? organizeVideos = null; string? organizeVideos = null;
string? organizeAudio = null; string? organizeAudio = null;
@@ -105,6 +125,8 @@ public sealed class UiPreferencesStore
double? windowTop = null; double? windowTop = null;
var windowMaximized = false; var windowMaximized = false;
double? treeWidth = null; double? treeWidth = null;
var sessionTabs = new List<SessionTabState>();
var sessionActiveTab = 0;
foreach (var raw in lines) foreach (var raw in lines)
{ {
var line = raw.Trim(); var line = raw.Trim();
@@ -149,6 +171,14 @@ public sealed class UiPreferencesStore
{ {
autoClearQueue = IsTrue(value); autoClearQueue = IsTrue(value);
} }
else if (key.Equals("auto-index-removable", StringComparison.OrdinalIgnoreCase))
{
autoIndexRemovable = IsTrue(value);
}
else if (key.Equals("background-host-at-logon", StringComparison.OrdinalIgnoreCase))
{
backgroundHostAtLogon = IsTrue(value);
}
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase)) else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{ {
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value; sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
@@ -157,6 +187,10 @@ public sealed class UiPreferencesStore
{ {
gitPath = string.IsNullOrWhiteSpace(value) ? null : value; 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)) else if (key.Equals("organize-pictures", StringComparison.OrdinalIgnoreCase))
{ {
organizePictures = EmptyToNull(value); organizePictures = EmptyToNull(value);
@@ -209,13 +243,30 @@ public sealed class UiPreferencesStore
{ {
treeWidth = ParseDouble(value); 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( return new UiPreferences(
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue, theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, ffmpegPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives, organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment); organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon, sessionTabs, sessionActiveTab);
} }
private static IEnumerable<string> SevenZipLines(UiPreferences preferences) private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
@@ -234,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) private static IEnumerable<string> OrganizeLines(UiPreferences preferences)
{ {
if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures)) if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures))
@@ -305,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 string Format(double value) => value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
private static double? ParseDouble(string value) private static double? ParseDouble(string value)

View File

@@ -0,0 +1,58 @@
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class WorkbenchHost : IWorkbenchHost
{
public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers, ISourceHost sources, IIndexMutations mutations)
{
Indexing = indexing;
Transfers = transfers;
Sources = sources;
Mutations = mutations;
}
public IIndexingHost Indexing { get; }
public ITransferHost Transfers { get; }
public ISourceHost Sources { get; }
public IIndexMutations Mutations { get; }
}
public sealed class LocalSourceHost : ISourceHost
{
private readonly SourceManager _sources;
public LocalSourceHost(SourceManager sources) => _sources = sources;
public Task RefreshAsync(CancellationToken cancellationToken = default)
=> _sources.RefreshOnlineStateAsync(forceRefresh: true, cancellationToken);
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> _sources.AddUncAsync(path, cancellationToken);
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> _sources.EnsureForPathAsync(path, cancellationToken);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> _sources.ForgetDisconnectedAsync(path, cancellationToken);
}
public sealed class LocalIndexMutations : IIndexMutations
{
private readonly IIndexStore _store;
public LocalIndexMutations(IIndexStore store) => _store = store;
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.UpsertAsync(profile, cancellationToken);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.DeleteAsync(id, cancellationToken);
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.UpsertAsync(profile, cancellationToken);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.DeleteAsync(id, cancellationToken);
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> _store.RenameBatches.CreateAsync(items, cancellationToken);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default)
=> _store.RenameBatches.MarkUndoneAsync(id, cancellationToken);
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
=> _store.Hashes.EnqueueSizeCollisionsAsync(sourceId, cancellationToken);
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
=> _store.Relations.UpsertAsync(relation, cancellationToken);
}

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

@@ -0,0 +1,78 @@
using Explorer.Domain;
namespace Explorer.Contracts;
public interface IWorkbenchHost
{
IIndexingHost Indexing { get; }
ITransferHost Transfers { get; }
ISourceHost Sources { get; }
IIndexMutations Mutations { get; }
}
public interface IIndexingHost
{
event EventHandler<ScanProgress>? ProgressChanged;
void EnqueueFullScan(long sourceId);
void EnqueueFolderScan(long sourceId, string pathRel);
void EnqueueReconcile(long sourceId, string pathRel);
void Cancel(long sourceId);
}
public interface ITransferHost
{
event EventHandler? Changed;
event EventHandler<TransferJob>? JobFinished;
bool IsPaused { get; }
IReadOnlyList<TransferJob> Snapshot();
void PauseAll();
void ResumeAll();
void Pause(long jobId);
void Resume(long jobId);
void Retry(long jobId);
void Cancel(long jobId);
void Dismiss(long jobId);
void ClearFinished();
bool MoveUp(long jobId);
bool MoveDown(long jobId);
Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> 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
{
Task RefreshAsync(CancellationToken cancellationToken = default);
Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default);
Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default);
}
public interface IIndexMutations
{
Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default);
Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default);
Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default);
Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default);
Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default);
Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default);
}

View File

@@ -20,6 +20,8 @@ public interface IIndexStore
ISyncProfileStore SyncProfiles { get; } ISyncProfileStore SyncProfiles { get; }
IOperationProfileStore OperationProfiles { get; } IOperationProfileStore OperationProfiles { get; }
bool CanWrite { get; }
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default); Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default); Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
} }

View File

@@ -5,7 +5,7 @@ public static class AppConstants
public const string ProductFolderName = "ExplorerWorkbench"; public const string ProductFolderName = "ExplorerWorkbench";
public const string DatabaseFileName = "index.db"; public const string DatabaseFileName = "index.db";
public const string LogFolderName = "logs"; public const string LogFolderName = "logs";
public const int SchemaVersion = 8; public const int SchemaVersion = 9;
public const int DefaultTombstoneRetentionDays = 30; public const int DefaultTombstoneRetentionDays = 30;
public const int ScanBatchSize = 3000; public const int ScanBatchSize = 3000;
public const int SearchPageSize = 500; 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, Extract,
Compress, Compress,
AddToArchive, AddToArchive,
VerifyArchive VerifyArchive,
Convert
} }
public enum ArchiveFormat public enum ArchiveFormat
@@ -107,6 +108,13 @@ public enum ArchiveFormat
SevenZip SevenZip
} }
public enum ConversionKind
{
VideoToMp4,
ExtractAudio,
HeicToJpeg
}
public enum TransferStatus public enum TransferStatus
{ {
Queued, Queued,

View File

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

View File

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

View File

@@ -3,11 +3,14 @@
<RootNamespace>Explorer.FileOperations</RootNamespace> <RootNamespace>Explorer.FileOperations</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" 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="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="SharpCompress" Version="0.50.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" /> <ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" /> <ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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 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 NameExists = "A file with that name already exists.";
public const string DestinationUnavailable = "Destination unavailable"; 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) 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

@@ -1,16 +1,16 @@
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
namespace Explorer.FileOperations; namespace Explorer.FileOperations;
public sealed class FileOperationService public sealed class FileOperationService
{ {
private readonly TransferQueue _queue; private readonly ITransferHost _queue;
private readonly IShellFileOperations _shell; private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator; private readonly IFileSystemEnumerator _enumerator;
public FileOperationService(TransferQueue queue, IShellFileOperations shell, IFileSystemEnumerator enumerator) public FileOperationService(ITransferHost queue, IShellFileOperations shell, IFileSystemEnumerator enumerator)
{ {
_queue = queue; _queue = queue;
_shell = shell; _shell = shell;
@@ -88,6 +88,20 @@ public sealed class FileOperationService
public Task VerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default) public Task VerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> _queue.EnqueueVerifyArchiveAsync(archivePath, cancellationToken); => _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) public static string UniqueArchivePath(string directory, string stem, string extension)
{ {
extension = extension.Trim().TrimStart('.'); extension = extension.Trim().TrimStart('.');

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -9,6 +10,7 @@ public sealed class FolderSyncService
{ {
private readonly FolderSyncPlanner _planner; private readonly FolderSyncPlanner _planner;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly IIndexMutations _mutations;
private readonly SourceManager _sources; private readonly SourceManager _sources;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
private readonly IVolumeService _volumes; private readonly IVolumeService _volumes;
@@ -20,6 +22,7 @@ public sealed class FolderSyncService
public FolderSyncService( public FolderSyncService(
FolderSyncPlanner planner, FolderSyncPlanner planner,
IIndexStore store, IIndexStore store,
IIndexMutations mutations,
SourceManager sources, SourceManager sources,
FileOperationService ops, FileOperationService ops,
IVolumeService volumes, IVolumeService volumes,
@@ -28,6 +31,7 @@ public sealed class FolderSyncService
{ {
_planner = planner; _planner = planner;
_store = store; _store = store;
_mutations = mutations;
_sources = sources; _sources = sources;
_ops = ops; _ops = ops;
_volumes = volumes; _volumes = volumes;
@@ -46,12 +50,12 @@ public sealed class FolderSyncService
} }
AttachVolumeGuids(profile); AttachVolumeGuids(profile);
profile.Id = await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); profile.Id = await _mutations.UpsertSyncProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id; return profile.Id;
} }
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.DeleteAsync(id, cancellationToken); => _mutations.DeleteSyncProfileAsync(id, cancellationToken);
public async Task<OperationPlan> PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default) public async Task<OperationPlan> PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default)
{ {
@@ -97,7 +101,7 @@ public sealed class FolderSyncService
profile.LastStatus = deletes > 0 profile.LastStatus = deletes > 0
? $"Queued {copies} copy, {deletes} delete" ? $"Queued {copies} copy, {deletes} delete"
: $"Queued {copies} copy"; : $"Queued {copies} copy";
await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); await _mutations.UpsertSyncProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return plan; return plan;
} }
@@ -171,7 +175,7 @@ public sealed class FolderSyncService
return; return;
} }
await _store.Relations.UpsertAsync(new FileRelation await _mutations.UpsertRelationAsync(new FileRelation
{ {
LeftEntryId = left.Id, LeftEntryId = left.Id,
RightEntryId = right.Id, RightEntryId = right.Id,

View File

@@ -10,23 +10,26 @@ public sealed class NativeFileOperationExecutor : IOperationExecutor
private readonly IFileSystemEnumerator _enumerator; private readonly IFileSystemEnumerator _enumerator;
private readonly IArchiveExecutor? _archives; private readonly IArchiveExecutor? _archives;
private readonly IHydrationGuard? _hydration; private readonly IHydrationGuard? _hydration;
private readonly IMediaConversionProvider? _conversion;
public NativeFileOperationExecutor( public NativeFileOperationExecutor(
IShellFileOperations shell, IShellFileOperations shell,
IFileSystemEnumerator enumerator, IFileSystemEnumerator enumerator,
IArchiveExecutor? archives = null, IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null) IHydrationGuard? hydration = null,
IMediaConversionProvider? conversion = null)
{ {
_shell = shell; _shell = shell;
_enumerator = enumerator; _enumerator = enumerator;
_archives = archives; _archives = archives;
_hydration = hydration; _hydration = hydration;
_conversion = conversion;
} }
public bool CanExecute(TransferOp op) public bool CanExecute(TransferOp op)
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename => op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress 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) 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: case TransferOp.VerifyArchive:
await ArchiveAsync(job, reportProgress, cancellationToken).ConfigureAwait(false); await ArchiveAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break; break;
case TransferOp.Convert:
await ConvertAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
default: default:
job.Status = TransferStatus.Failed; job.Status = TransferStatus.Failed;
job.Error = $"Unsupported operation {job.Op}"; 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) private static IReadOnlyList<string> ArchiveSources(TransferJob job)
=> job.AdditionalSources.Count > 0 => job.AdditionalSources.Count > 0
? job.AdditionalSources ? job.AdditionalSources

View File

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

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -9,6 +10,7 @@ public sealed class OperationProfileService
{ {
private readonly FileOperationProfilePlanner _planner; private readonly FileOperationProfilePlanner _planner;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly IIndexMutations _mutations;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
private readonly RenameBatchService _renames; private readonly RenameBatchService _renames;
private readonly IVolumeService _volumes; private readonly IVolumeService _volumes;
@@ -16,21 +18,25 @@ public sealed class OperationProfileService
private readonly IGitStatusProvider _git; private readonly IGitStatusProvider _git;
private readonly IHydrationGuard _hydration; private readonly IHydrationGuard _hydration;
private readonly IArchiveExecutor _archives; private readonly IArchiveExecutor _archives;
private readonly IMediaConversionProvider _conversion;
private readonly ConcurrentDictionary<long, bool> _autoRunOnline = []; private readonly ConcurrentDictionary<long, bool> _autoRunOnline = [];
public OperationProfileService( public OperationProfileService(
FileOperationProfilePlanner planner, FileOperationProfilePlanner planner,
IIndexStore store, IIndexStore store,
IIndexMutations mutations,
FileOperationService ops, FileOperationService ops,
RenameBatchService renames, RenameBatchService renames,
IVolumeService volumes, IVolumeService volumes,
IFileSystemEnumerator enumerator, IFileSystemEnumerator enumerator,
IGitStatusProvider git, IGitStatusProvider git,
IHydrationGuard hydration, IHydrationGuard hydration,
IArchiveExecutor archives) IArchiveExecutor archives,
IMediaConversionProvider conversion)
{ {
_planner = planner; _planner = planner;
_store = store; _store = store;
_mutations = mutations;
_ops = ops; _ops = ops;
_renames = renames; _renames = renames;
_volumes = volumes; _volumes = volumes;
@@ -38,6 +44,7 @@ public sealed class OperationProfileService
_git = git; _git = git;
_hydration = hydration; _hydration = hydration;
_archives = archives; _archives = archives;
_conversion = conversion;
} }
public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default)
@@ -55,12 +62,12 @@ public sealed class OperationProfileService
AttachVolumeGuids(profile); AttachVolumeGuids(profile);
profile.AutoRun = profile.CanAutoRun; profile.AutoRun = profile.CanAutoRun;
profile.Id = await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); profile.Id = await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id; return profile.Id;
} }
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.DeleteAsync(id, cancellationToken); => _mutations.DeleteOperationProfileAsync(id, cancellationToken);
public async Task<OperationProfile> DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default) public async Task<OperationProfile> DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default)
{ {
@@ -109,7 +116,9 @@ public sealed class OperationProfileService
_archives.IsAvailable, _archives.IsAvailable,
_archives.MissingHint, _archives.MissingHint,
RenameBatchService.PathExists, RenameBatchService.PathExists,
item => _hydration.WouldHydrateOnRead(item)); item => _hydration.WouldHydrateOnRead(item),
_conversion.IsAvailable,
_conversion.MissingHint);
} }
public async Task<OperationPlan> EnqueueAsync( public async Task<OperationPlan> EnqueueAsync(
@@ -138,6 +147,13 @@ public sealed class OperationProfileService
var parts = op.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries); var parts = op.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
await _ops.CompressAsync(parts, op.DestinationPath, cancellationToken).ConfigureAwait(false); 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) else if (op.Op == TransferOp.Copy && op.DestinationPath is not null)
{ {
await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken) await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
@@ -147,10 +163,11 @@ public sealed class OperationProfileService
var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy); var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy);
var compress = plan.Operations.Count(o => o.Op == TransferOp.Compress); var compress = plan.Operations.Count(o => o.Op == TransferOp.Compress);
var convert = plan.Operations.Count(o => o.Op == TransferOp.Convert);
var renamed = renames.Count; var renamed = renames.Count;
profile.LastRunUtc = DateTimeOffset.UtcNow; 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 _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return plan; return plan;
} }
@@ -206,7 +223,7 @@ public sealed class OperationProfileService
} }
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
await _store.OperationProfiles.UpsertAsync(new OperationProfile await _mutations.UpsertOperationProfileAsync(new OperationProfile
{ {
Name = "Archive folder", Name = "Archive folder",
RequireGitClean = true, RequireGitClean = true,
@@ -216,7 +233,7 @@ public sealed class OperationProfileService
IsBuiltIn = true, IsBuiltIn = true,
CreatedUtc = now CreatedUtc = now
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
await _store.OperationProfiles.UpsertAsync(new OperationProfile await _mutations.UpsertOperationProfileAsync(new OperationProfile
{ {
Name = "Copy to destination", Name = "Copy to destination",
DoCopy = true, DoCopy = true,
@@ -224,6 +241,14 @@ public sealed class OperationProfileService
IsBuiltIn = true, IsBuiltIn = true,
CreatedUtc = now CreatedUtc = now
}, cancellationToken).ConfigureAwait(false); }, 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) private void AttachVolumeGuids(OperationProfile profile)
@@ -248,6 +273,8 @@ public sealed class OperationProfileService
RequireGitClean = profile.RequireGitClean, RequireGitClean = profile.RequireGitClean,
DoCompress = profile.DoCompress, DoCompress = profile.DoCompress,
ArchiveFormat = profile.ArchiveFormat, ArchiveFormat = profile.ArchiveFormat,
DoConvert = profile.DoConvert,
ConversionKind = profile.ConversionKind,
DoCopy = profile.DoCopy, DoCopy = profile.DoCopy,
DoRename = profile.DoRename, DoRename = profile.DoRename,
RenamePrefix = profile.RenamePrefix, RenamePrefix = profile.RenamePrefix,

View File

@@ -1,4 +1,5 @@
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -8,12 +9,14 @@ public sealed class RenameBatchService
{ {
private readonly RenamePlanner _planner; private readonly RenamePlanner _planner;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly IIndexMutations _mutations;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
public RenameBatchService(RenamePlanner planner, IIndexStore store, FileOperationService ops) public RenameBatchService(RenamePlanner planner, IIndexStore store, IIndexMutations mutations, FileOperationService ops)
{ {
_planner = planner; _planner = planner;
_store = store; _store = store;
_mutations = mutations;
_ops = ops; _ops = ops;
} }
@@ -36,7 +39,7 @@ public sealed class RenameBatchService
var items = plan.Operations var items = plan.Operations
.Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i)) .Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i))
.ToList(); .ToList();
var batchId = await _store.RenameBatches.CreateAsync(items, cancellationToken).ConfigureAwait(false); var batchId = await _mutations.CreateRenameBatchAsync(items, cancellationToken).ConfigureAwait(false);
await _ops.EnqueueRenameAsync( await _ops.EnqueueRenameAsync(
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(), plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken) cancellationToken)
@@ -63,7 +66,7 @@ public sealed class RenameBatchService
{ {
if (!plan.HasErrors) if (!plan.HasErrors)
{ {
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); await _mutations.MarkRenameBatchUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
} }
return plan; return plan;
@@ -73,7 +76,7 @@ public sealed class RenameBatchService
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(), plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); await _mutations.MarkRenameBatchUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
return plan; return plan;
} }
} }

View File

@@ -1,3 +1,4 @@
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -5,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.FileOperations; namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService public sealed class TransferQueue : BackgroundService, ITransferHost
{ {
private readonly IOperationExecutor _executor; private readonly IOperationExecutor _executor;
private readonly IIndexStore _store; private readonly IIndexStore _store;
@@ -156,6 +157,17 @@ public sealed class TransferQueue : BackgroundService
CreatedUtc = DateTimeOffset.UtcNow CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken); }, 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() public void PauseAll()
{ {
_queuePaused = true; _queuePaused = true;

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<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>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" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Hosting\Explorer.Hosting.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,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

@@ -0,0 +1,48 @@
using Explorer.Host;
using Explorer.Windows;
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,
shared: true)
.CreateLogger();
try
{
return await HostEntry.RunAsync(args, Boot).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already in use", StringComparison.OrdinalIgnoreCase))
{
Log.Warning(ex, "Index store is already owned; background host exiting");
return 1;
}
catch (Exception ex)
{
Log.Fatal(ex, "Explorer.Host failed");
return 1;
}
finally
{
await Log.CloseAndFlushAsync().ConfigureAwait(false);
}

View File

@@ -0,0 +1,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

@@ -0,0 +1,65 @@
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Explorer.Domain;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchIpcOptions
{
public string PipeName { get; set; } = WorkbenchIpc.DefaultPipeName;
}
public static class WorkbenchIpc
{
public const int ProtocolVersion = 1;
public static string DefaultPipeName { get; } = Sanitize("ExplorerWorkbench-" + Environment.UserName);
public static 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,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static string Sanitize(string name)
{
var chars = name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
return new string(chars);
}
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);
}
public sealed class IpcEnvelope
{
public int V { get; set; } = WorkbenchIpc.ProtocolVersion;
public string? Id { get; set; }
public string? Op { get; set; }
public string? Evt { get; set; }
public bool? Ok { get; set; }
public string? Error { get; set; }
public long? N { get; set; }
public string? S { get; set; }
public bool? Flag { get; set; }
public bool? Paused { get; set; }
public string? Dest { get; set; }
public string[]? Paths { get; set; }
public string? Payload { get; set; }
public ScanProgress? Progress { get; set; }
public TransferJob? Job { get; set; }
public TransferJob[]? Jobs { get; set; }
public Source? Source { get; set; }
}

View File

@@ -0,0 +1,535 @@
using System.Collections.Concurrent;
using System.IO.Pipes;
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, ICloudOverlay, IHostConnection, IAsyncDisposable
{
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 IndexingProxy _indexing;
private readonly TransferProxy _transfers;
private readonly SourceProxy _sources;
private readonly MutationProxy _mutations;
private bool _disposed;
private bool _suppressRestart;
private WorkbenchPipeClient(NamedPipeClientStream pipe, WorkbenchIpcOptions options)
{
_options = options;
_pipe = pipe;
_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);
_mutations = new MutationProxy(this);
_readLoop = ReadLoopAsync(_cts.Token);
}
public IIndexingHost Indexing => _indexing;
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,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var deadline = DateTime.UtcNow + timeout;
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
cancellationToken.ThrowIfCancellationRequested();
var pipe = new NamedPipeClientStream(
".",
options.PipeName,
PipeDirection.InOut,
WorkbenchIpc.StreamOptions);
WorkbenchPipeClient? client = null;
try
{
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
{
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);
}
return client;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
last = ex;
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(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
}
}
throw new TimeoutException(
$"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();
internal async Task<IpcEnvelope> CallAsync(
string op,
CancellationToken cancellationToken,
long? n = null,
string? s = null,
string? dest = null,
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);
_pending[id] = tcs;
var request = new IpcEnvelope
{
V = WorkbenchIpc.ProtocolVersion,
Id = id,
Op = op,
N = n,
S = s,
Dest = dest,
Paths = paths,
Flag = flag,
Payload = payload
};
var json = JsonSerializer.Serialize(request, WorkbenchIpc.Json);
await _send.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
catch
{
_pending.TryRemove(id, out _);
throw;
}
finally
{
_send.Release();
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token, _cts.Token);
using var cancelReg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
try
{
var reply = await tcs.Task.ConfigureAwait(false);
if (reply.Ok == false)
{
throw new InvalidOperationException(reply.Error ?? "Host call failed.");
}
return reply;
}
finally
{
_pending.TryRemove(id, out _);
}
}
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);
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
IpcEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<IpcEnvelope>(line, WorkbenchIpc.Json);
}
catch (JsonException)
{
continue;
}
if (envelope is null)
{
continue;
}
if (!string.IsNullOrEmpty(envelope.Evt))
{
switch (envelope.Evt)
{
case "Indexing.Progress" when envelope.Progress is not null:
RaiseProgress(envelope.Progress);
break;
case "Transfers.Changed":
RaiseChanged();
break;
case "Transfers.JobFinished" when envelope.Job is not null:
RaiseFinished(envelope.Job);
break;
case "Host.Stopping":
_suppressRestart = true;
StatusChanged?.Invoke(this, "Background host stopped");
break;
}
continue;
}
if (envelope.Id is not null && _pending.TryRemove(envelope.Id, out var tcs))
{
tcs.TrySetResult(envelope);
}
}
}
catch (OperationCanceledException)
{
// shutting down
}
catch (IOException)
{
if (!_disposed && !_suppressRestart)
{
StatusChanged?.Invoke(this, "Host disconnected — reconnecting…");
}
}
finally
{
foreach (var tcs in _pending.Values)
{
tcs.TrySetCanceled(cancellationToken);
}
}
}
public async ValueTask DisposeAsync()
{
_disposed = true;
await _cts.CancelAsync().ConfigureAwait(false);
try { await _writer.DisposeAsync().ConfigureAwait(false); } catch { /* pipe already closed */ }
_reader.Dispose();
await _pipe.DisposeAsync().ConfigureAwait(false);
try
{
await _readLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
catch (TimeoutException) { }
catch (OperationCanceledException) { }
_cts.Dispose();
_send.Dispose();
}
private sealed class IndexingProxy : IIndexingHost
{
private readonly WorkbenchPipeClient _client;
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public IndexingProxy(WorkbenchPipeClient client) => _client = client;
public void EnqueueFullScan(long sourceId) => _client.Call("Indexing.EnqueueFullScan", sourceId);
public void EnqueueFolderScan(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
public void EnqueueReconcile(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel);
public void Cancel(long sourceId) => _client.Call("Indexing.Cancel", sourceId);
public void Raise(ScanProgress progress) => ProgressChanged?.Invoke(this, progress);
}
private sealed class TransferProxy : ITransferHost
{
private readonly WorkbenchPipeClient _client;
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public TransferProxy(WorkbenchPipeClient client) => _client = client;
public bool IsPaused => _client.Call("Transfers.IsPaused").Paused == true;
public IReadOnlyList<TransferJob> Snapshot() => _client.Call("Transfers.Snapshot").Jobs ?? [];
public void PauseAll() => _client.Call("Transfers.PauseAll");
public void ResumeAll() => _client.Call("Transfers.ResumeAll");
public void Pause(long jobId) => _client.Call("Transfers.Pause", jobId);
public void Resume(long jobId) => _client.Call("Transfers.Resume", jobId);
public void Retry(long jobId) => _client.Call("Transfers.Retry", jobId);
public void Cancel(long jobId) => _client.Call("Transfers.Cancel", jobId);
public void Dismiss(long jobId) => _client.Call("Transfers.Dismiss", jobId);
public void ClearFinished() => _client.Call("Transfers.ClearFinished");
public bool MoveUp(long jobId) => _client.Call("Transfers.MoveUp", jobId).Flag == true;
public bool MoveDown(long jobId) => _client.Call("Transfers.MoveDown", jobId).Flag == true;
public Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent);
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueRename", cancellationToken, s: path, dest: newName);
public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueEmptyRecycleBin", cancellationToken);
public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueExtract", cancellationToken, s: archivePath, dest: destinationDirectory);
public Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueCompress", cancellationToken, dest: archivePath, paths: sources.ToArray());
public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> _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);
}
private sealed class SourceProxy : ISourceHost
{
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)
=> (await _client.CallAsync("Sources.AddUnc", cancellationToken, s: path).ConfigureAwait(false)).Source
?? throw new InvalidOperationException("Host did not return a source.");
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> 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;
}
private sealed class MutationProxy : IIndexMutations
{
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)
=> _client.CallAsync("Mutations.DeleteSyncProfile", cancellationToken, n: id);
public async Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Mutations.UpsertOperationProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0;
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.DeleteOperationProfile", cancellationToken, n: id);
public async Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Mutations.CreateRenameBatch", cancellationToken, payload: Json(items)).ConfigureAwait(false)).N ?? 0;
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.MarkRenameBatchUndone", cancellationToken, n: id);
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
=> _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

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>Explorer.Hosting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.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" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Explorer.Hosting.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,112 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public static class ExplorerHostServices
{
public static IServiceCollection AddExplorerCore(this IServiceCollection services)
{
services.AddExplorerHostRuntime();
services.AddExplorerWorkers();
services.AddExplorerOperations();
return services;
}
public static IServiceCollection AddExplorerHostRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IClock, SystemClock>();
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger, 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>());
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
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<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<RenamePlanner>();
return services;
}
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
{
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<ITransferHost>(sp => sp.GetRequiredService<TransferQueue>());
services.AddSingleton<IIndexingHost>(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddSingleton<IIndexMutations>(sp => new LocalIndexMutations(sp.GetRequiredService<IIndexStore>()));
services.AddSingleton<IWorkbenchHost>(sp => new WorkbenchHost(
sp.GetRequiredService<IIndexingHost>(),
sp.GetRequiredService<ITransferHost>(),
new LocalSourceHost(sp.GetRequiredService<SourceManager>()),
sp.GetRequiredService<IIndexMutations>()));
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService<WatcherHostedService>();
return services;
}
public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
{
services.TryAddSingleton<Explorer.Hosting.Ipc.WorkbenchIpcOptions>();
services.AddHostedService<IndexStoreLifetime>();
services.AddExplorerCore();
return services;
}
}

View File

@@ -0,0 +1,504 @@
using System.Collections.Concurrent;
using System.IO.Pipes;
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;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeServer : BackgroundService
{
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(
IServiceProvider services,
WorkbenchIpcOptions options,
ILogger<WorkbenchPipeServer> logger)
{
_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,
4,
PipeTransmissionMode.Byte,
WorkbenchIpc.StreamOptions);
_listening.TrySetResult();
using var cancelPipe = stoppingToken.Register(() =>
{
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)
{
break;
}
catch (IOException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Workbench pipe accept failed");
try
{
await Task.Delay(250, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
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, 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);
void OnChanged(object? sender, EventArgs e)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.Changed" }, stoppingToken);
void OnFinished(object? sender, TransferJob job)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
var hooked = false;
try
{
while (!stoppingToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(stoppingToken).ConfigureAwait(false);
if (line is null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
IpcEnvelope request;
try
{
request = JsonSerializer.Deserialize<IpcEnvelope>(line, WorkbenchIpc.Json)
?? new IpcEnvelope { Ok = false, Error = "empty" };
}
catch (JsonException ex)
{
await WriteAsync(writer, new IpcEnvelope { Ok = false, Error = ex.Message }, stoppingToken)
.ConfigureAwait(false);
continue;
}
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
{
_writers.TryRemove(writer, out _);
if (hooked && _workbench is not null)
{
Workbench.Indexing.ProgressChanged -= OnProgress;
Workbench.Transfers.Changed -= OnChanged;
Workbench.Transfers.JobFinished -= OnFinished;
}
}
}
internal IpcEnvelope Handle(IpcEnvelope request)
=> HandleAsync(request).GetAwaiter().GetResult();
internal async Task<IpcEnvelope> HandleAsync(IpcEnvelope request)
{
var reply = new IpcEnvelope { Id = request.Id, Ok = true };
if (request.V != WorkbenchIpc.ProtocolVersion)
{
reply.Ok = false;
reply.Error = $"Unsupported protocol {request.V}; expected {WorkbenchIpc.ProtocolVersion}.";
return reply;
}
try
{
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);
return reply;
case "Indexing.EnqueueFolderScan":
Workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.EnqueueReconcile":
Workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.Cancel":
Workbench.Indexing.Cancel(request.N ?? 0);
return reply;
case "Transfers.Snapshot":
reply.Jobs = Workbench.Transfers.Snapshot().ToArray();
reply.Paused = Workbench.Transfers.IsPaused;
return reply;
case "Transfers.IsPaused":
reply.Paused = Workbench.Transfers.IsPaused;
return reply;
case "Transfers.PauseAll":
Workbench.Transfers.PauseAll();
return reply;
case "Transfers.ResumeAll":
Workbench.Transfers.ResumeAll();
return reply;
case "Transfers.Pause":
Workbench.Transfers.Pause(request.N ?? 0);
return reply;
case "Transfers.Resume":
Workbench.Transfers.Resume(request.N ?? 0);
return reply;
case "Transfers.Retry":
Workbench.Transfers.Retry(request.N ?? 0);
return reply;
case "Transfers.Cancel":
Workbench.Transfers.Cancel(request.N ?? 0);
return reply;
case "Transfers.Dismiss":
Workbench.Transfers.Dismiss(request.N ?? 0);
return reply;
case "Transfers.ClearFinished":
Workbench.Transfers.ClearFinished();
return reply;
case "Transfers.MoveUp":
reply.Flag = Workbench.Transfers.MoveUp(request.N ?? 0);
return reply;
case "Transfers.MoveDown":
reply.Flag = Workbench.Transfers.MoveDown(request.N ?? 0);
return reply;
case "Transfers.EnqueueCopy":
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);
return reply;
case "Transfers.EnqueueDelete":
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);
return reply;
case "Transfers.EnqueueEmptyRecycleBin":
await Workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false);
return reply;
case "Transfers.EnqueueExtract":
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);
return reply;
case "Transfers.EnqueueAddToArchive":
await Workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false);
return reply;
case "Transfers.EnqueueVerifyArchive":
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);
return reply;
case "Sources.AddUnc":
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);
return reply;
case "Sources.Forget":
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);
return reply;
case "Mutations.DeleteSyncProfile":
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);
return reply;
case "Mutations.DeleteOperationProfile":
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);
return reply;
case "Mutations.MarkRenameBatchUndone":
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);
return reply;
case "Mutations.UpsertRelation":
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;
reply.Error = "Unknown op " + request.Op;
return reply;
}
}
catch (Exception ex)
{
reply.Ok = false;
reply.Error = ex.Message;
return reply;
}
}
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);
private async Task WriteAsync(StreamWriter writer, IpcEnvelope envelope, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(envelope, WorkbenchIpc.Json);
await _write.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_write.Release();
}
}
}

View File

@@ -0,0 +1,65 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting;
public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
private readonly IIndexStore _store;
private readonly IIndexingHost _indexing;
private readonly UiPreferencesStore _preferences;
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles,
IIndexStore store,
IIndexingHost indexing,
UiPreferencesStore preferences)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
_store = store;
_indexing = indexing;
_preferences = preferences;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await TryAutoIndexRemovableAsync(stoppingToken).ConfigureAwait(false);
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}
private async Task TryAutoIndexRemovableAsync(CancellationToken cancellationToken)
{
var prefs = _preferences.Load();
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var sourceId in RemovableAutoIndexPlanner.SourceIdsToScan(sources, prefs.AutoIndexRemovable))
{
_indexing.EnqueueFullScan(sourceId);
}
}
}

View File

@@ -5,10 +5,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" 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="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="SharpCompress" Version="0.50.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" /> <ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" /> <ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@@ -10,9 +10,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" /> <ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" /> <ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" /> <ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" /> <ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" /> <ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" /> <ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -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

@@ -3,14 +3,14 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis; using Explorer.Analysis;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels; namespace Explorer.Presentation.ViewModels;
public sealed partial class DuplicateViewModel : ObservableObject public sealed partial class DuplicateViewModel : ObservableObject
{ {
private readonly IIndexStore _store; private readonly IIndexMutations _mutations;
private readonly SourceManager _sources; private readonly SourceManager _sources;
private readonly AnalysisService _analysis; private readonly AnalysisService _analysis;
@@ -20,9 +20,9 @@ public sealed partial class DuplicateViewModel : ObservableObject
[ObservableProperty] private bool _showIntentional; [ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks; [ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis) public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis)
{ {
_store = store; _mutations = mutations;
_sources = sources; _sources = sources;
_analysis = analysis; _analysis = analysis;
Groups = []; Groups = [];
@@ -50,7 +50,7 @@ public sealed partial class DuplicateViewModel : ObservableObject
{ {
var groups = await Task.Run(async () => var groups = await Task.Run(async () =>
{ {
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false); await _mutations.EnqueueHashCollisionsAsync(null).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false); var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id); var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
return classified return classified

View File

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

View File

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

@@ -2,9 +2,9 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.FileOperations; using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Search; using Explorer.Search;
using Explorer.Analysis; using Explorer.Analysis;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -16,10 +16,10 @@ public sealed partial class MainViewModel : ObservableObject
{ {
private readonly BrowseService _browse; private readonly BrowseService _browse;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing; private readonly IIndexingHost _indexing;
private readonly SourceManager _sources; private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory; private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers; private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces; private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences; private readonly UiPreferencesStore _preferences;
private readonly RenamePlanner _renamePlanner; private readonly RenamePlanner _renamePlanner;
@@ -31,7 +31,12 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IGitCommandProvider _gitCommands; private readonly IGitCommandProvider _gitCommands;
private readonly IWorkspaceLauncher _workspace; private readonly IWorkspaceLauncher _workspace;
private readonly IHydrationGuard _hydration; private readonly IHydrationGuard _hydration;
private readonly ConversionPlanner _conversionPlanner;
private readonly IFileSystemEnumerator _enumerator;
private readonly IMediaConversionProvider _conversion;
private readonly IThumbnailService? _thumbnails; private readonly IThumbnailService? _thumbnails;
private readonly IHostConnection? _host;
private bool _hostStopped;
private List<string> _clipboard = []; private List<string> _clipboard = [];
private bool _clipboardIsCut; private bool _clipboardIsCut;
@@ -51,6 +56,7 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _canUndoRenameBatch; [ObservableProperty] private bool _canUndoRenameBatch;
[ObservableProperty] private bool _showExtractArchive; [ObservableProperty] private bool _showExtractArchive;
[ObservableProperty] private bool _showCompress; [ObservableProperty] private bool _showCompress;
[ObservableProperty] private bool _showConvert;
[ObservableProperty] private bool _showAddToArchive; [ObservableProperty] private bool _showAddToArchive;
[ObservableProperty] private bool _showVerifyArchive; [ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal; [ObservableProperty] private bool _showOpenTerminal;
@@ -63,15 +69,14 @@ public sealed partial class MainViewModel : ObservableObject
public MainViewModel( public MainViewModel(
BrowseService browse, BrowseService browse,
FileOperationService ops, FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources, SourceManager sources,
SearchService search, SearchService search,
AnalysisService analysis, AnalysisService analysis,
IIndexStore store, IIndexMutations mutations,
TransferQueue transfers, IWorkbenchHost workbench,
IOsClipboard clipboard, IOsClipboard clipboard,
PathHistoryStore pathHistory, PathHistoryStore pathHistory,
StorageProviderRegistry providers, ICloudOverlay providers,
CloudPlaceStore cloudPlaces, CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences, UiPreferencesStore preferences,
IVolumeService volumes, IVolumeService volumes,
@@ -84,11 +89,15 @@ public sealed partial class MainViewModel : ObservableObject
IWorkspaceLauncher workspace, IWorkspaceLauncher workspace,
IGitCommandProvider gitCommands, IGitCommandProvider gitCommands,
IHydrationGuard hydration, IHydrationGuard hydration,
IThumbnailService? thumbnails = null) ConversionPlanner conversionPlanner,
IFileSystemEnumerator enumerator,
IMediaConversionProvider conversion,
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null)
{ {
_browse = browse; _browse = browse;
_ops = ops; _ops = ops;
_indexing = indexing; _indexing = workbench.Indexing;
_sources = sources; _sources = sources;
_pathHistory = pathHistory; _pathHistory = pathHistory;
_providers = providers; _providers = providers;
@@ -103,19 +112,38 @@ public sealed partial class MainViewModel : ObservableObject
_gitCommands = gitCommands; _gitCommands = gitCommands;
_workspace = workspace; _workspace = workspace;
_hydration = hydration; _hydration = hydration;
_conversionPlanner = conversionPlanner;
_enumerator = enumerator;
_conversion = conversion;
_thumbnails = thumbnails; _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(); var prefs = preferences.Load();
Theme = prefs.Theme; Theme = prefs.Theme;
PathHistory = []; PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences); Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources, volumes); Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis); Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources, analysis); Duplicates = new DuplicateViewModel(mutations, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path); Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
Transfers = new TransferQueueViewModel(transfers, preferences); Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
Tabs = []; Tabs = [];
Clipboard = clipboard; Clipboard = clipboard;
transfers.JobFinished += (_, job) => workbench.Transfers.JobFinished += (_, job) =>
{ {
void Go() => _ = OnTransferFinishedAsync(job); void Go() => _ = OnTransferFinishedAsync(job);
if (_ui is { } ctx) if (_ui is { } ctx)
@@ -169,8 +197,7 @@ public sealed partial class MainViewModel : ObservableObject
return; return;
} }
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails); var tab = CreateTab();
WireTab(tab);
Tabs.Add(tab); Tabs.Add(tab);
ActiveTab = tab; ActiveTab = tab;
PathText = tab.ActivePane.CurrentPath; PathText = tab.ActivePane.CurrentPath;
@@ -188,17 +215,32 @@ public sealed partial class MainViewModel : ObservableObject
PathHistory.Add(path); PathHistory.Add(path);
} }
await ActiveTab.OpenInitialAsync().ConfigureAwait(true); await RestoreSessionAsync().ConfigureAwait(true);
PathText = ActivePane.CurrentPath; PathText = ActivePane.CurrentPath;
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true); 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] [RelayCommand]
public async Task NewTabAsync() public async Task NewTabAsync()
{ {
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails); var tab = CreateTab();
WireTab(tab);
Tabs.Add(tab); Tabs.Add(tab);
ActiveTab = tab; ActiveTab = tab;
await tab.OpenInitialAsync().ConfigureAwait(true); await tab.OpenInitialAsync().ConfigureAwait(true);
@@ -480,6 +522,38 @@ public sealed partial class MainViewModel : ObservableObject
public OperationProfilesViewModel CreateOperationProfilesViewModel() public OperationProfilesViewModel CreateOperationProfilesViewModel()
=> new(_operationProfiles); => 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() public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath()); => new(_reorganize, OrganizeSourcePath());
@@ -668,6 +742,7 @@ public sealed partial class MainViewModel : ObservableObject
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList(); var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
ShowExtractArchive = real.Count > 0 && real.All(i => !i.IsDirectory && ArchiveFormats.IsArchive(i.Item.Name)); ShowExtractArchive = real.Count > 0 && real.All(i => !i.IsDirectory && ArchiveFormats.IsArchive(i.Item.Name));
ShowCompress = real.Count > 0; 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)); ShowAddToArchive = real.Any(i => i.IsDirectory || !ArchiveFormats.IsArchive(i.Item.Name));
ShowVerifyArchive = ShowExtractArchive; ShowVerifyArchive = ShowExtractArchive;
var target = WorkspaceDirectory(); var target = WorkspaceDirectory();
@@ -985,7 +1060,7 @@ public sealed partial class MainViewModel : ObservableObject
var trimmed = path.Trim().TrimEnd('\\'); var trimmed = path.Trim().TrimEnd('\\');
var id = providerId var id = providerId
?? _providers.Find(trimmed)?.Manifest.Id ?? _providers.FindProviderId(trimmed)
?? GuessCloudProvider(trimmed); ?? GuessCloudProvider(trimmed);
var name = string.IsNullOrWhiteSpace(displayName) ? CloudProviderLabel(id) : displayName; var name = string.IsNullOrWhiteSpace(displayName) ? CloudProviderLabel(id) : displayName;
_cloudPlaces.Add(id, trimmed, name); _cloudPlaces.Add(id, trimmed, name);
@@ -1046,6 +1121,8 @@ public sealed partial class MainViewModel : ObservableObject
public void SaveLayout(double width, double height, double left, double top, bool maximized, double treeWidth) public void SaveLayout(double width, double height, double left, double top, bool maximized, double treeWidth)
{ {
var stored = _preferences.Load(); var stored = _preferences.Load();
var tabs = Tabs.Select(tab => tab.Capture()).ToList();
var active = Math.Max(0, Tabs.IndexOf(ActiveTab));
_preferences.Save(stored with _preferences.Save(stored with
{ {
Theme = UiPreferencesStore.NormalizeTheme(Theme), Theme = UiPreferencesStore.NormalizeTheme(Theme),
@@ -1054,10 +1131,46 @@ public sealed partial class MainViewModel : ObservableObject
WindowLeft = left, WindowLeft = left,
WindowTop = top, WindowTop = top,
WindowMaximized = maximized, 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) public async Task ApplyPreferencesAsync(UiPreferences preferences)
{ {
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) }; 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 SourceManager _sources;
private readonly BrowseService _browse; private readonly BrowseService _browse;
private readonly StorageProviderRegistry _providers; private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces; private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences; private readonly UiPreferencesStore _preferences;
public NavigationTreeViewModel( public NavigationTreeViewModel(
SourceManager sources, SourceManager sources,
BrowseService browse, BrowseService browse,
StorageProviderRegistry providers, ICloudOverlay providers,
CloudPlaceStore cloudPlaces, CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences) UiPreferencesStore preferences)
{ {

View File

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

View File

@@ -2,8 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels; namespace Explorer.Presentation.ViewModels;
@@ -84,6 +84,11 @@ public sealed partial class TransferJobViewModel : ObservableObject
return FileName(job.DestinationPath); return FileName(job.DestinationPath);
} }
if (job.Op == TransferOp.Convert)
{
return FileName(job.DestinationPath);
}
return FileName(job.SourcePath); return FileName(job.SourcePath);
} }
@@ -97,6 +102,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Compress => $"Compress to {FileName(job.DestinationPath)}", TransferOp.Compress => $"Compress to {FileName(job.DestinationPath)}",
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}", TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive", TransferOp.VerifyArchive => "Verify archive",
TransferOp.Convert => $"Convert to {FileName(job.DestinationPath)}",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin", TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal) TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently" ? "Delete permanently"
@@ -179,6 +185,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Compress => "Compressing", TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding", TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying", TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin", TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working" _ => "Working"
}; };
@@ -186,7 +193,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
public sealed partial class TransferQueueViewModel : ObservableObject public sealed partial class TransferQueueViewModel : ObservableObject
{ {
private readonly TransferQueue _queue; private readonly ITransferHost _queue;
private readonly UiPreferencesStore _preferences; private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current; private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = []; private readonly Dictionary<long, (long Bytes, DateTime Utc)> _speed = [];
@@ -205,7 +212,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
[ObservableProperty] private double _overallProgress; [ObservableProperty] private double _overallProgress;
[ObservableProperty] private bool _hasOverallProgress; [ObservableProperty] private bool _hasOverallProgress;
public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences) public TransferQueueViewModel(ITransferHost queue, UiPreferencesStore preferences)
{ {
_queue = queue; _queue = queue;
_preferences = preferences; _preferences = preferences;
@@ -475,6 +482,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.Compress => "Compressing", TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding", TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying", TransferOp.VerifyArchive => "Verifying",
TransferOp.Convert => "Converting",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin", TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString() _ => op.ToString()
}; };

View File

@@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace Explorer.Storage.Sqlite;
/// <summary>
/// Per-user lock so only one process (and one in-process owner) can open the index for write.
/// GUI and Explorer.Host.exe must not share a writer. Named mutex recovers from a crashed owner;
/// an in-process table stops the same thread re-entering the mutex.
/// </summary>
public sealed class IndexStoreLock : IDisposable
{
private static readonly ConcurrentDictionary<string, byte> InProcessOwners = new(StringComparer.Ordinal);
private readonly Mutex _mutex;
private readonly string _name;
private bool _owned;
private IndexStoreLock(Mutex mutex, string name)
{
_mutex = mutex;
_name = name;
_owned = true;
}
public static string MutexNameFor(string databasePath)
{
var full = Path.GetFullPath(databasePath);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(full.ToUpperInvariant())))[..16];
return @"Local\ExplorerWorkbench-Index-" + hash;
}
public static 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);
if (!InProcessOwners.TryAdd(name, 0))
{
throw new InvalidOperationException(
"The Explorer Workbench index is already in use by another process.");
}
var mutex = new Mutex(false, name);
var wait = timeout ?? TimeSpan.Zero;
try
{
if (!mutex.WaitOne(wait))
{
mutex.Dispose();
InProcessOwners.TryRemove(name, out _);
throw new InvalidOperationException(
"The Explorer Workbench index is already in use by another process.");
}
}
catch (AbandonedMutexException)
{
// Previous owner crashed; this process now owns the mutex.
}
catch
{
mutex.Dispose();
InProcessOwners.TryRemove(name, out _);
throw;
}
return new IndexStoreLock(mutex, name);
}
public void Dispose()
{
if (_owned)
{
try
{
_mutex.ReleaseMutex();
}
catch (ApplicationException)
{
// Already released or not owned.
}
_owned = false;
InProcessOwners.TryRemove(_name, out _);
}
_mutex.Dispose();
}
}

View File

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

View File

@@ -10,16 +10,19 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
{ {
private readonly string _path; private readonly string _path;
private readonly ILogger<SqliteIndexStore> _logger; private readonly ILogger<SqliteIndexStore> _logger;
private readonly bool _readOnly;
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal<int> _writeDepth = new(); private readonly AsyncLocal<int> _writeDepth = new();
private SqliteConnection? _write; private SqliteConnection? _write;
private IndexStoreLock? _lock;
private bool _opened; private bool _opened;
private int _analysisIndexesReady; private int _analysisIndexesReady;
public SqliteIndexStore(string databasePath, ILogger<SqliteIndexStore> logger) public SqliteIndexStore(string databasePath, ILogger<SqliteIndexStore> logger, bool readOnly = false)
{ {
_path = databasePath; _path = databasePath;
_logger = logger; _logger = logger;
_readOnly = readOnly;
Sources = new SourceStore(this); Sources = new SourceStore(this);
Entries = new EntryStore(this); Entries = new EntryStore(this);
Excludes = new ExcludeStore(this); Excludes = new ExcludeStore(this);
@@ -49,6 +52,8 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
public ISyncProfileStore SyncProfiles { get; } public ISyncProfileStore SyncProfiles { get; }
public IOperationProfileStore OperationProfiles { get; } public IOperationProfileStore OperationProfiles { get; }
public bool CanWrite => !_readOnly;
internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open."); internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open.");
public async Task OpenAsync(CancellationToken cancellationToken = default) public async Task OpenAsync(CancellationToken cancellationToken = default)
@@ -58,6 +63,15 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return; return;
} }
if (_readOnly)
{
await OpenReadOnlyAsync(cancellationToken).ConfigureAwait(false);
return;
}
_lock = IndexStoreLock.Acquire(_path);
try
{
DapperSetup.Ensure(); DapperSetup.Ensure();
Directory.CreateDirectory(Path.GetDirectoryName(_path)!); Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
_write = new SqliteConnection(BuildConnectionString(_path)); _write = new SqliteConnection(BuildConnectionString(_path));
@@ -72,8 +86,59 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
_opened = true; _opened = true;
_logger.LogInformation("Opened index database at {Path}", _path); _logger.LogInformation("Opened index database at {Path}", _path);
} }
catch
{
_lock.Dispose();
_lock = null;
throw;
}
}
private async Task OpenReadOnlyAsync(CancellationToken cancellationToken)
{
DapperSetup.Ensure();
Exception? last = null;
for (var attempt = 0; attempt < 40; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!File.Exists(_path))
{
throw new InvalidOperationException("Index database is not ready yet.");
}
_write = new SqliteConnection(BuildConnectionString(_path, readOnly: true));
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyReadPragmas(_write);
if (IndexExists(_write, "ix_entries_dir_agg_all"))
{
Volatile.Write(ref _analysisIndexesReady, 1);
}
_opened = true;
_logger.LogInformation("Opened index database read-only at {Path}", _path);
return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
last = ex;
if (_write is not null)
{
await _write.DisposeAsync().ConfigureAwait(false);
_write = null;
}
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
}
}
throw new InvalidOperationException("Could not open the index for read. Is Explorer.Host.exe running?", last);
}
public async Task CloseAsync() public async Task CloseAsync()
{
try
{ {
if (_write is not null) if (_write is not null)
{ {
@@ -81,8 +146,13 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
await _write.DisposeAsync().ConfigureAwait(false); await _write.DisposeAsync().ConfigureAwait(false);
_write = null; _write = null;
} }
}
finally
{
_opened = false; _opened = false;
_lock?.Dispose();
_lock = null;
}
} }
public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default) public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default)
@@ -96,6 +166,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
public async Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default) public async Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default)
{ {
if (_readOnly)
{
throw new InvalidOperationException("Index store is open read-only.");
}
await EnterWriteAsync(cancellationToken).ConfigureAwait(false); await EnterWriteAsync(cancellationToken).ConfigureAwait(false);
var outermost = _writeDepth.Value == 1; var outermost = _writeDepth.Value == 1;
SqliteTransaction? tx = null; SqliteTransaction? tx = null;
@@ -147,6 +222,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
throw new InvalidOperationException("Store is not open."); throw new InvalidOperationException("Store is not open.");
} }
if (_readOnly)
{
throw new InvalidOperationException("Index store is open read-only.");
}
await EnterWriteAsync(cancellationToken).ConfigureAwait(false); await EnterWriteAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
@@ -193,17 +273,17 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
internal async Task<SqliteConnection> OpenReadAsync(CancellationToken cancellationToken) internal async Task<SqliteConnection> OpenReadAsync(CancellationToken cancellationToken)
{ {
var conn = new SqliteConnection(BuildConnectionString(_path)); var conn = new SqliteConnection(BuildConnectionString(_path, _readOnly));
await conn.OpenAsync(cancellationToken).ConfigureAwait(false); await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyReadPragmas(conn); ApplyReadPragmas(conn);
return conn; return conn;
} }
internal static string BuildConnectionString(string path) internal static string BuildConnectionString(string path, bool readOnly = false)
=> new SqliteConnectionStringBuilder => new SqliteConnectionStringBuilder
{ {
DataSource = path, DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate, Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate,
Pooling = false Pooling = false
}.ToString(); }.ToString();
@@ -231,6 +311,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
if (_readOnly)
{
return Task.CompletedTask;
}
return WriteAsync(conn => return WriteAsync(conn =>
{ {
EnsureAnalysisIndexes(conn); EnsureAnalysisIndexes(conn);
@@ -475,6 +560,8 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
require_git_clean INTEGER NOT NULL DEFAULT 0, require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0, do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip', 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_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0, do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '', rename_prefix TEXT NOT NULL DEFAULT '',
@@ -496,6 +583,15 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
SetUserVersion(conn, 8); SetUserVersion(conn, 8);
_logger.LogInformation("Migrated SQLite schema to v8 (operation profiles)"); _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
}
}
}

View File

@@ -21,6 +21,7 @@ public sealed class WindowsElevatedScanService : IElevatedScanService
} }
} }
// Queue, index writer, and watchers stay in the user session. Elevation is never requested.
public bool CanRequestElevation => false; public bool CanRequestElevation => false;
public string ProtectedContentHint => "Protected content requires administrator privileges."; public string ProtectedContentHint => "Protected content requires administrator privileges.";

View File

@@ -0,0 +1,109 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application.Tests;
public class ConversionPlannerTests
{
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
[Fact]
public void Video_files_become_mp4_outputs()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.File(@"C:\src\notes.txt", 1, T0)
.Dir(@"C:\dst");
var plan = Build([@"C:\src"], @"C:\dst", ConversionKind.VideoToMp4, fs);
Assert.True(plan.CanEnqueue);
var op = Assert.Single(plan.Operations);
Assert.Equal(TransferOp.Convert, op.Op);
Assert.Equal(@"C:\src\clip.mov", op.SourcePath);
Assert.Equal(@"C:\dst\clip.mp4", op.DestinationPath);
Assert.Contains(plan.Issues, i => i.Message.Contains("not a match", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Online_only_files_are_skipped()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.Dir(@"C:\dst");
var plan = Build(
[@"C:\src\clip.mov"],
@"C:\dst",
ConversionKind.VideoToMp4,
fs,
wouldHydrate: item => item.Name == "clip.mov");
Assert.False(plan.CanEnqueue);
Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Missing_ffmpeg_is_an_error()
{
var fs = Tree().Dir(@"C:\src").File(@"C:\src\clip.mov", 10, T0).Dir(@"C:\dst");
var plan = Build([@"C:\src\clip.mov"], @"C:\dst", ConversionKind.VideoToMp4, fs, ffmpegAvailable: false);
Assert.False(plan.CanEnqueue);
Assert.Contains(plan.Issues, i => i.Message.Contains("FFmpeg", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Unique_names_avoid_overwrite()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.File(@"C:\src\clip.mkv", 10, T0)
.Dir(@"C:\dst");
var plan = Build(
[@"C:\src"],
@"C:\dst",
ConversionKind.VideoToMp4,
fs,
pathExists: path => path.Equals(@"C:\dst\clip.mp4", StringComparison.OrdinalIgnoreCase));
Assert.True(plan.CanEnqueue);
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\dst\clip (2).mp4");
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\dst\clip (3).mp4");
}
[Fact]
public void Heic_maps_to_jpeg()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\IMG_0001.HEIC", 4, T0)
.Dir(@"C:\dst");
var plan = Build([@"C:\src\IMG_0001.HEIC"], @"C:\dst", ConversionKind.HeicToJpeg, fs);
var op = Assert.Single(plan.Operations);
Assert.Equal(@"C:\dst\IMG_0001.jpg", op.DestinationPath);
Assert.Equal(nameof(ConversionKind.HeicToJpeg), op.NewName);
}
private static OperationPlan Build(
string[] sources,
string dest,
ConversionKind kind,
IFileSystemEnumerator fs,
bool ffmpegAvailable = true,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
=> new ConversionPlanner().Build(
sources,
dest,
kind,
fs,
ffmpegAvailable,
FfmpegLocator.MissingHint,
pathExists ?? (_ => false),
wouldHydrate);
private static TreeEnumerator Tree() => new();
}

View File

@@ -0,0 +1,30 @@
using Explorer.Application;
namespace Explorer.Application.Tests;
public class FfmpegLocatorTests
{
[Fact]
public void Prefers_the_configured_path_when_it_exists()
{
var path = @"C:\Tools\ffmpeg.exe";
Assert.Equal(path, FfmpegLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
}
[Fact]
public void Finds_ffmpeg_on_PATH_when_not_configured()
{
var found = FfmpegLocator.Find(
null,
fileExists: p => p.Equals(@"D:\bin\ffmpeg.exe", StringComparison.OrdinalIgnoreCase),
pathVariable: @"C:\Windows;D:\bin");
Assert.Equal(@"D:\bin\ffmpeg.exe", found);
}
[Fact]
public void Returns_null_when_ffmpeg_is_missing()
{
Assert.Null(FfmpegLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
Assert.Contains("FFmpeg", FfmpegLocator.MissingHint, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -160,6 +160,44 @@ public class FileOperationProfilePlannerTests
Assert.Contains(plan.Issues, i => i.Message.Contains("7-Zip", StringComparison.OrdinalIgnoreCase)); Assert.Contains(plan.Issues, i => i.Message.Contains("7-Zip", StringComparison.OrdinalIgnoreCase));
} }
[Fact]
public void Convert_lists_one_job_per_matching_file()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.File(@"C:\src\notes.txt", 1, T0)
.Dir(@"C:\dst");
var plan = Build(new OperationProfile
{
Name = "Convert",
DestPath = @"C:\dst",
DoConvert = true,
ConversionKind = ConversionKind.VideoToMp4
}, fs, [@"C:\src"]);
Assert.True(plan.CanEnqueue);
var convert = Assert.Single(plan.Operations);
Assert.Equal(TransferOp.Convert, convert.Op);
Assert.Equal(@"C:\src\clip.mov", convert.SourcePath);
Assert.Equal(@"C:\dst\clip.mp4", convert.DestinationPath);
Assert.Contains(plan.ProfilePreview, r => r.Action == "Convert");
}
[Fact]
public void Missing_ffmpeg_is_an_error()
{
var fs = Tree().Dir(@"C:\src").File(@"C:\src\clip.mov", 10, T0).Dir(@"C:\dst");
var plan = Build(new OperationProfile
{
Name = "Convert",
DestPath = @"C:\dst",
DoConvert = true
}, fs, [@"C:\src"], convertAvailable: false);
Assert.False(plan.CanEnqueue);
Assert.Contains(plan.Issues, i => i.Message.Contains("FFmpeg", StringComparison.OrdinalIgnoreCase));
}
private static OperationProfile CopyProfile(string dest = @"C:\dst", bool git = false) private static OperationProfile CopyProfile(string dest = @"C:\dst", bool git = false)
=> new() => new()
{ {
@@ -176,6 +214,7 @@ public class FileOperationProfilePlannerTests
GitStatus? git = null, GitStatus? git = null,
bool gitAvailable = true, bool gitAvailable = true,
bool compressAvailable = true, bool compressAvailable = true,
bool convertAvailable = true,
Func<string, bool>? reachable = null, Func<string, bool>? reachable = null,
Func<FileSystemItem, bool>? wouldHydrate = null) Func<FileSystemItem, bool>? wouldHydrate = null)
=> new FileOperationProfilePlanner(new RenamePlanner()).Build( => new FileOperationProfilePlanner(new RenamePlanner()).Build(
@@ -188,7 +227,9 @@ public class FileOperationProfilePlannerTests
compressAvailable, compressAvailable,
SevenZipLocator.MissingHint, SevenZipLocator.MissingHint,
pathExists: _ => false, pathExists: _ => false,
wouldHydrate: wouldHydrate); wouldHydrate: wouldHydrate,
convertAvailable: convertAvailable,
convertMissingHint: FfmpegLocator.MissingHint);
private static TreeEnumerator Tree() => new(); private static TreeEnumerator Tree() => new();
} }

View File

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

View File

@@ -25,6 +25,7 @@ public class UiPreferencesStoreTests
Assert.True(prefs.AutoClearQueueWhenDone); Assert.True(prefs.AutoClearQueueWhenDone);
Assert.Equal(@"C:\Program Files\7-Zip\7z.exe", prefs.SevenZipPath); Assert.Equal(@"C:\Program Files\7-Zip\7z.exe", prefs.SevenZipPath);
Assert.Null(prefs.GitPath); Assert.Null(prefs.GitPath);
Assert.Null(prefs.FfmpegPath);
} }
[Fact] [Fact]
@@ -43,6 +44,25 @@ public class UiPreferencesStoreTests
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath); Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
} }
[Fact]
public void Parse_reads_ffmpeg_path()
{
var prefs = UiPreferencesStore.Parse(["ffmpeg=C:\\Tools\\ffmpeg.exe"]);
Assert.Equal(@"C:\Tools\ffmpeg.exe", prefs.FfmpegPath);
}
[Fact]
public void Parse_reads_host_and_removable_index_flags()
{
var prefs = UiPreferencesStore.Parse(
[
"auto-index-removable=true",
"background-host-at-logon=true"
]);
Assert.True(prefs.AutoIndexRemovable);
Assert.True(prefs.BackgroundHostAtLogon);
}
[Fact] [Fact]
public void Parse_defaults_missing_keys() public void Parse_defaults_missing_keys()
{ {
@@ -54,8 +74,12 @@ public class UiPreferencesStoreTests
Assert.True(prefs.ShowHiddenFiles); Assert.True(prefs.ShowHiddenFiles);
Assert.False(prefs.ShowProtectedSystemLocations); Assert.False(prefs.ShowProtectedSystemLocations);
Assert.False(prefs.AutoClearQueueWhenDone); Assert.False(prefs.AutoClearQueueWhenDone);
Assert.False(prefs.AutoIndexRemovable);
Assert.False(prefs.BackgroundHostAtLogon);
Assert.Null(prefs.SevenZipPath); Assert.Null(prefs.SevenZipPath);
Assert.Null(prefs.GitPath); Assert.Null(prefs.GitPath);
Assert.Null(prefs.FfmpegPath);
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
} }
[Fact] [Fact]
@@ -77,6 +101,43 @@ public class UiPreferencesStoreTests
Assert.Equal(24, prefs.WindowTop); Assert.Equal(24, prefs.WindowTop);
Assert.True(prefs.WindowMaximized); Assert.True(prefs.WindowMaximized);
Assert.Equal(320, prefs.TreeWidth); Assert.Equal(320, prefs.TreeWidth);
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
}
[Fact]
public void Parse_reads_session_tabs_and_active_index()
{
var left = @"C:\Users\Dominique\Documents";
var right = @"D:\Photos";
var prefs = UiPreferencesStore.Parse(
[
"session-active-tab=1",
"session-tab=" + UiPreferencesStore.FormatSessionTab(new SessionTabState(LocationRoots.ThisPc)),
"session-tab=" + UiPreferencesStore.FormatSessionTab(new SessionTabState(left, right, true, 0.42, true))
]);
Assert.Equal(1, prefs.SessionActiveTab);
Assert.NotNull(prefs.SessionTabs);
Assert.Equal(2, prefs.SessionTabs.Count);
Assert.Equal(LocationRoots.ThisPc, prefs.SessionTabs[0].LeftPath);
Assert.False(prefs.SessionTabs[0].IsSplit);
Assert.Equal(left, prefs.SessionTabs[1].LeftPath);
Assert.Equal(right, prefs.SessionTabs[1].RightPath);
Assert.True(prefs.SessionTabs[1].IsSplit);
Assert.Equal(0.42, prefs.SessionTabs[1].SplitRatio);
Assert.True(prefs.SessionTabs[1].ActiveIsRight);
}
[Fact]
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
{
var state = new SessionTabState(@"C:\weird;name", @"\\server\share", true, 0.3, false);
var parsed = UiPreferencesStore.TryParseSessionTab(UiPreferencesStore.FormatSessionTab(state));
Assert.NotNull(parsed);
Assert.Equal(state.LeftPath, parsed.LeftPath);
Assert.Equal(state.RightPath, parsed.RightPath);
Assert.True(parsed.IsSplit);
Assert.Equal(0.3, parsed.SplitRatio);
Assert.False(parsed.ActiveIsRight);
} }
[Fact] [Fact]
@@ -86,7 +147,17 @@ public class UiPreferencesStoreTests
try try
{ {
var store = new UiPreferencesStore(new PrefsEnv(dir)); var store = new UiPreferencesStore(new PrefsEnv(dir));
store.Save(new UiPreferences("Light", true, false, true, AutoClearQueueWhenDone: true, WindowWidth: 1100, WindowHeight: 720, TreeWidth: 300)); store.Save(new UiPreferences(
"Light", true, false, true,
AutoClearQueueWhenDone: true,
WindowWidth: 1100,
WindowHeight: 720,
TreeWidth: 300,
SessionTabs:
[
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
],
SessionActiveTab: 0));
var loaded = store.Load(); var loaded = store.Load();
Assert.Equal("Light", loaded.Theme); Assert.Equal("Light", loaded.Theme);
Assert.True(loaded.GroupNetworkPlaces); Assert.True(loaded.GroupNetworkPlaces);
@@ -95,9 +166,18 @@ public class UiPreferencesStoreTests
Assert.True(loaded.ShowHiddenFiles); Assert.True(loaded.ShowHiddenFiles);
Assert.False(loaded.ShowProtectedSystemLocations); Assert.False(loaded.ShowProtectedSystemLocations);
Assert.True(loaded.AutoClearQueueWhenDone); Assert.True(loaded.AutoClearQueueWhenDone);
Assert.False(loaded.AutoIndexRemovable);
Assert.False(loaded.BackgroundHostAtLogon);
Assert.Equal(1100, loaded.WindowWidth); Assert.Equal(1100, loaded.WindowWidth);
Assert.Equal(720, loaded.WindowHeight); Assert.Equal(720, loaded.WindowHeight);
Assert.Equal(300, loaded.TreeWidth); Assert.Equal(300, loaded.TreeWidth);
Assert.NotNull(loaded.SessionTabs);
var tab = Assert.Single(loaded.SessionTabs);
Assert.Equal(@"C:\Temp", tab.LeftPath);
Assert.Equal(@"D:\", tab.RightPath);
Assert.True(tab.IsSplit);
Assert.Equal(0.6, tab.SplitRatio);
Assert.True(tab.ActiveIsRight);
} }
finally finally
{ {

View File

@@ -0,0 +1,90 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class WorkbenchHostTests
{
[Fact]
public void Forwards_indexing_and_transfer_sessions()
{
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
var sources = new StubSources();
var mutations = new StubMutations();
IWorkbenchHost host = new WorkbenchHost(indexing, transfers, sources, mutations);
Assert.Same(indexing, host.Indexing);
Assert.Same(transfers, host.Transfers);
Assert.Same(sources, host.Sources);
Assert.Same(mutations, host.Mutations);
host.Indexing.EnqueueFullScan(7);
host.Transfers.PauseAll();
Assert.Equal(7, indexing.FullScanId);
Assert.True(transfers.PausedAll);
}
private sealed class FakeIndexing : IIndexingHost
{
public long FullScanId { get; private set; }
public event EventHandler<ScanProgress>? ProgressChanged;
public void EnqueueFullScan(long sourceId) => FullScanId = sourceId;
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
public void Raise() => ProgressChanged?.Invoke(this, new ScanProgress
{
SourceId = 1,
CurrentPath = "",
Status = ScanJobStatus.Running
});
}
private sealed class FakeTransfers : ITransferHost
{
public bool PausedAll { get; private set; }
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => PausedAll;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() => PausedAll = true;
public void ResumeAll() => PausedAll = false;
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -110,6 +110,29 @@ public class ArchiveFormatsTests
} }
} }
public class ConversionFormatsTests
{
[Fact]
public void Matches_video_audio_and_heic()
{
Assert.True(ConversionFormats.Matches("clip.MOV", ConversionKind.VideoToMp4));
Assert.True(ConversionFormats.Matches("talk.wav", ConversionKind.ExtractAudio));
Assert.True(ConversionFormats.Matches("film.mkv", ConversionKind.ExtractAudio));
Assert.True(ConversionFormats.Matches("IMG_0001.heic", ConversionKind.HeicToJpeg));
Assert.False(ConversionFormats.Matches("notes.txt", ConversionKind.VideoToMp4));
Assert.False(ConversionFormats.IsConvertible("notes.txt"));
Assert.True(ConversionFormats.IsConvertible("phone.mp4"));
Assert.Equal("mp4", ConversionFormats.Extension(ConversionKind.VideoToMp4));
Assert.Equal("m4a", ConversionFormats.Extension(ConversionKind.ExtractAudio));
Assert.Equal("jpg", ConversionFormats.Extension(ConversionKind.HeicToJpeg));
Assert.Equal(ConversionKind.VideoToMp4, ConversionFormats.Preferred(["clip.mov", "notes.txt"]));
Assert.Equal(ConversionKind.HeicToJpeg, ConversionFormats.Preferred(["IMG.HEIC"]));
Assert.Equal(ConversionKind.VideoToMp4, ConversionFormats.Infer(@"C:\a.mov", @"C:\a.mp4"));
Assert.Equal(ConversionKind.ExtractAudio, ConversionFormats.Infer(@"C:\a.mov", @"C:\a.m4a"));
Assert.Equal(ConversionKind.HeicToJpeg, ConversionFormats.Infer(@"C:\a.heic", @"C:\a.jpg"));
}
}
public class VolumeIdentityTests public class VolumeIdentityTests
{ {
[Fact] [Fact]
@@ -386,6 +409,9 @@ public class OperationProfileTests
var compress = new OperationProfile { Name = "Archive", DoCopy = true, DoCompress = true, AutoRun = true }; var compress = new OperationProfile { Name = "Archive", DoCopy = true, DoCompress = true, AutoRun = true };
Assert.False(compress.CanAutoRun); Assert.False(compress.CanAutoRun);
var convert = new OperationProfile { Name = "Convert", DoCopy = true, DoConvert = true, AutoRun = true };
Assert.False(convert.CanAutoRun);
var rename = new OperationProfile { Name = "Rename", DoCopy = true, DoRename = true, RenamePrefix = "x_", AutoRun = true }; var rename = new OperationProfile { Name = "Rename", DoCopy = true, DoRename = true, RenamePrefix = "x_", AutoRun = true };
Assert.False(rename.CanAutoRun); Assert.False(rename.CanAutoRun);
} }

View File

@@ -109,12 +109,14 @@ public class FolderSyncServiceTests
store, store,
volumes, volumes,
NullLogger<TransferQueue>.Instance); NullLogger<TransferQueue>.Instance);
var mutations = new LocalIndexMutations(store);
var ops = new FileOperationService(queue, shell, enumerator); var ops = new FileOperationService(queue, shell, enumerator);
var env = new SyncEnv(root); var env = new SyncEnv(root);
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance); var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
var sync = new FolderSyncService( var sync = new FolderSyncService(
new FolderSyncPlanner(), new FolderSyncPlanner(),
store, store,
mutations,
sources, sources,
ops, ops,
volumes, volumes,

View File

@@ -61,6 +61,21 @@ public class OperationProfileServiceTests
Assert.Empty(ctx.Queue.Snapshot()); Assert.Empty(ctx.Queue.Snapshot());
} }
[Fact]
public async Task Convert_auto_run_is_ignored()
{
await using var ctx = await ProfileHarness.CreateAsync();
ctx.Profile.DoConvert = true;
ctx.Profile.AutoRun = true;
await ctx.Profiles.SaveAsync(ctx.Profile);
Assert.False((await ctx.Store.OperationProfiles.GetAsync(ctx.Profile.Id))!.CanAutoRun);
ctx.Volumes.Reachable = false;
await ctx.Profiles.TryAutoRunAsync();
ctx.Volumes.Reachable = true;
await ctx.Profiles.TryAutoRunAsync();
Assert.Empty(ctx.Queue.Snapshot());
}
private static async Task WaitUntil(Func<bool> condition) private static async Task WaitUntil(Func<bool> condition)
{ {
var limit = DateTime.UtcNow + TimeSpan.FromSeconds(4); var limit = DateTime.UtcNow + TimeSpan.FromSeconds(4);
@@ -107,17 +122,20 @@ public class OperationProfileServiceTests
var ops = new FileOperationService(queue, shell, enumerator); var ops = new FileOperationService(queue, shell, enumerator);
var git = new StubGit(); var git = new StubGit();
var planner = new FileOperationProfilePlanner(new RenamePlanner()); var planner = new FileOperationProfilePlanner(new RenamePlanner());
var renames = new RenameBatchService(new RenamePlanner(), store, ops); var mutations = new LocalIndexMutations(store);
var renames = new RenameBatchService(new RenamePlanner(), store, mutations, ops);
var profiles = new OperationProfileService( var profiles = new OperationProfileService(
planner, planner,
store, store,
mutations,
ops, ops,
renames, renames,
volumes, volumes,
enumerator, enumerator,
git, git,
new NeverHydrate(), new NeverHydrate(),
new FakeArchiveExecutor()); new FakeArchiveExecutor(),
new FakeConversionExecutor());
return new ProfileHarness return new ProfileHarness
{ {
Profiles = profiles, Profiles = profiles,

View File

@@ -316,7 +316,7 @@ public class TransferQueueTests
{ {
await using var ctx = await Harness.CreateAsync(); await using var ctx = await Harness.CreateAsync();
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum()); var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, ops); var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, new LocalIndexMutations(ctx.Store), ops);
await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.StartAsync(CancellationToken.None);
var subjects = new[] var subjects = new[]
{ {
@@ -395,6 +395,48 @@ public class TransferQueueTests
await ctx.Queue.StopAsync(CancellationToken.None); await ctx.Queue.StopAsync(CancellationToken.None);
} }
[Fact]
public async Task Convert_fails_when_ffmpeg_is_missing()
{
await using var ctx = await Harness.CreateAsync();
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
await ctx.Queue.StartAsync(CancellationToken.None);
await ops.ConvertAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "a.mp4"), ConversionKind.VideoToMp4);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
j.Op == TransferOp.Convert && j.Status == TransferStatus.Failed));
var job = ctx.Queue.Snapshot().Single(j => j.Op == TransferOp.Convert);
Assert.Contains("FFmpeg", job.Error, StringComparison.OrdinalIgnoreCase);
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Fake_convert_writes_output()
{
var fake = new FakeConversionExecutor();
await using var ctx = await Harness.CreateAsync(conversion: fake);
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
await ctx.Queue.StartAsync(CancellationToken.None);
var dest = Path.Combine(ctx.Dest, "a.mp4");
await ops.ConvertAsync(ctx.File("a.txt"), dest, ConversionKind.VideoToMp4);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
j.Op == TransferOp.Convert && j.Status == TransferStatus.Done));
Assert.True(File.Exists(dest));
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Convert_refuses_online_only_cloud_files()
{
await using var ctx = await Harness.CreateAsync(hydration: new AlwaysHydrate(), conversion: new FakeConversionExecutor());
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
await ctx.Queue.StartAsync(CancellationToken.None);
await ops.ConvertAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "a.mp4"), ConversionKind.VideoToMp4);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
j.Op == TransferOp.Convert && j.Status == TransferStatus.Failed));
Assert.Equal(FileOperationErrors.CloudHydration, ctx.Queue.Snapshot().Single().Error);
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact] [Fact]
public async Task Empty_recycle_bin_goes_through_the_queue() public async Task Empty_recycle_bin_goes_through_the_queue()
{ {
@@ -438,7 +480,8 @@ public class TransferQueueTests
public static async Task<Harness> CreateAsync( public static async Task<Harness> CreateAsync(
IArchiveExecutor? archives = null, IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null) IHydrationGuard? hydration = null,
IMediaConversionProvider? conversion = null)
{ {
var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root); Directory.CreateDirectory(root);
@@ -452,7 +495,7 @@ public class TransferQueueTests
var shell = new GateShell(); var shell = new GateShell();
var volumes = new ControlledVolumes(); var volumes = new ControlledVolumes();
var queue = new TransferQueue( var queue = new TransferQueue(
new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration), new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration, conversion),
store, store,
volumes, volumes,
NullLogger<TransferQueue>.Instance); NullLogger<TransferQueue>.Instance);
@@ -629,6 +672,24 @@ internal sealed class FakeArchiveExecutor : IArchiveExecutor
=> Task.CompletedTask; => Task.CompletedTask;
} }
internal sealed class FakeConversionExecutor : IMediaConversionProvider
{
public bool IsAvailable { get; set; } = true;
public string MissingHint => FfmpegLocator.MissingHint;
public async Task ConvertAsync(
string sourcePath,
string destinationPath,
ConversionKind kind,
IProgress<ConversionProgress>? progress,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
await File.WriteAllTextAsync(destinationPath, "converted:" + kind, cancellationToken).ConfigureAwait(false);
progress?.Report(new ConversionProgress(100, destinationPath));
}
}
internal sealed class AlwaysHydrate : IHydrationGuard internal sealed class AlwaysHydrate : IHydrationGuard
{ {
public bool WouldHydrateOnRead(FileSystemItem item) => true; public bool WouldHydrateOnRead(FileSystemItem item) => true;

View File

@@ -0,0 +1,162 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Hosting;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Search;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting.Tests;
public class CoreRegistrationTests
{
[Fact]
public async Task AddExplorerCore_registers_workbench_without_clipboard()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IAppEnvironment>(new TempEnv(dir));
services.AddExplorerCore();
await using var sp = services.BuildServiceProvider();
Assert.NotNull(sp.GetService<IWorkbenchHost>());
Assert.NotNull(sp.GetService<IIndexingHost>());
Assert.NotNull(sp.GetService<ITransferHost>());
Assert.NotNull(sp.GetService<IIndexMutations>());
Assert.Null(sp.GetService<IOsClipboard>());
Assert.NotNull(sp.GetService<FilesystemScanner>());
Assert.NotEmpty(sp.GetServices<IStorageProvider>());
Assert.NotNull(sp.GetService<ICloudOverlay>());
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
}
finally
{
try { Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
[Fact]
public async Task AddExplorerClient_uses_read_only_store_without_indexing_workers()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var workbench = new StubWorkbench();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IAppEnvironment>(new TempEnv(dir));
services.AddExplorerClient(workbench);
await using var sp = services.BuildServiceProvider();
Assert.Same(workbench, sp.GetService<IWorkbenchHost>());
Assert.Same(workbench.Indexing, sp.GetService<IIndexingHost>());
Assert.Same(workbench.Transfers, sp.GetService<ITransferHost>());
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
Assert.NotNull(sp.GetService<BrowseService>());
Assert.NotNull(sp.GetService<SearchService>());
Assert.NotNull(sp.GetService<AnalysisService>());
Assert.NotNull(sp.GetService<Explorer.FileOperations.FileOperationService>());
Assert.NotNull(sp.GetService<Explorer.FileOperations.FolderSyncService>());
Assert.Same(NullCloudOverlay.Instance, sp.GetService<ICloudOverlay>());
Assert.Null(sp.GetService<FilesystemScanner>());
Assert.Null(sp.GetService<FolderReconciler>());
Assert.Null(sp.GetService<UsnChangeApplier>());
Assert.Null(sp.GetService<ArchiveContentsIndexer>());
Assert.Null(sp.GetService<IUsnJournal>());
Assert.Null(sp.GetService<IElevatedScanService>());
Assert.Empty(sp.GetServices<IStorageProvider>());
var hosted = sp.GetServices<IHostedService>().ToList();
Assert.Contains(hosted, s => s is IndexStoreLifetime);
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
}
finally
{
try { Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
private sealed class TempEnv : IAppEnvironment
{
public TempEnv(string dir)
{
DataDirectory = dir;
DatabasePath = Path.Combine(dir, "index.db");
LogDirectory = Path.Combine(dir, "logs");
Directory.CreateDirectory(LogDirectory);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}
private sealed class StubWorkbench : IWorkbenchHost
{
public IIndexingHost Indexing { get; } = new StubIndexing();
public ITransferHost Transfers { get; } = new StubTransfers();
public ISourceHost Sources { get; } = new StubSources();
public IIndexMutations Mutations { get; } = new StubMutations();
}
private sealed class StubIndexing : IIndexingHost
{
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public void EnqueueFullScan(long sourceId) { }
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
}
private sealed class StubTransfers : ITransferHost
{
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => false;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() { }
public void ResumeAll() { }
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

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

View File

@@ -0,0 +1,16 @@
using Explorer.Hosting;
namespace Explorer.Hosting.Tests;
public class HostLogonAutostartTests
{
[Fact]
public void Run_command_is_the_quoted_host_exe_for_the_current_user()
{
var command = HostLogonAutostart.RunCommand(@"C:\Tools\Explorer.Host.exe");
Assert.Equal("\"C:\\Tools\\Explorer.Host.exe\"", command);
Assert.Equal("ExplorerWorkbenchHost", HostLogonAutostart.RunValueName);
Assert.DoesNotContain("/RU", command, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("ONSTART", command, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,67 @@
namespace Explorer.Hosting.Tests;
public class ProjectGraphTests
{
[Fact]
public void App_references_client_not_host_runtime_or_plugin_implementations()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.App", "Explorer.App.csproj"));
Assert.Contains("Explorer.Hosting.Client", csproj);
Assert.Contains("Explorer.Presentation", csproj);
Assert.DoesNotContain("Explorer.Hosting\\Explorer.Hosting.csproj", csproj);
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
Assert.DoesNotContain("Explorer.Indexing", csproj);
}
[Fact]
public void Client_does_not_reference_plugin_implementations_or_scanners()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Hosting.Client", "Explorer.Hosting.Client.csproj"));
Assert.Contains("Explorer.Plugin.Abstractions", csproj);
Assert.Contains("Explorer.Storage.Sqlite", csproj);
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
Assert.DoesNotContain("Explorer.Indexing", csproj);
Assert.DoesNotContain("Explorer.Hosting\\Explorer.Hosting.csproj", csproj);
}
[Fact]
public void Host_runtime_owns_plugins_and_references_the_client()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Hosting", "Explorer.Hosting.csproj"));
Assert.Contains("Explorer.Hosting.Client", csproj);
Assert.Contains("Explorer.Plugin.OneDrive", csproj);
Assert.Contains("Explorer.Plugin.GoogleDrive", csproj);
Assert.Contains("Explorer.Plugin.Nextcloud", csproj);
Assert.Contains("Explorer.Indexing", csproj);
}
[Fact]
public void Presentation_may_reference_plugin_abstractions_not_implementations()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Presentation", "Explorer.Presentation.csproj"));
Assert.Contains("Explorer.Plugin.Abstractions", csproj);
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "Explorer.slnx")))
{
return dir.FullName;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Could not find Explorer.slnx above " + AppContext.BaseDirectory);
}
}

View File

@@ -0,0 +1,226 @@
using System.Diagnostics;
using System.Text.Json;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Hosting.Ipc;
using Explorer.Plugin.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Explorer.Hosting.Tests;
public class WorkbenchPipeTests
{
[Fact]
public void Handle_forwards_indexing_and_transfer_calls()
{
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
var server = CreateServer(indexing, transfers);
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
Assert.True(ping.Ok);
var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 });
Assert.True(scan.Ok);
Assert.Equal(42, indexing.FullScanId);
var pause = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.PauseAll" });
Assert.True(pause.Ok);
Assert.True(transfers.PausedAll);
var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" });
Assert.True(paused.Paused);
var copy = server.Handle(new IpcEnvelope
{
V = WorkbenchIpc.ProtocolVersion,
Op = "Transfers.EnqueueCopy",
Paths = ["C:\\a.txt"],
Dest = "D:\\"
});
Assert.True(copy.Ok);
Assert.Equal(@"D:\", transfers.CopiedDest);
}
[Fact]
public void Handle_rejects_other_protocol_versions()
{
var server = CreateServer(new FakeIndexing(), new FakeTransfers());
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
Assert.False(reply.Ok);
Assert.Contains("99", reply.Error);
}
[Fact]
public async Task Connect_to_a_missing_pipe_fails_quickly()
{
var options = new WorkbenchIpcOptions { PipeName = "ew-missing-" + Guid.NewGuid().ToString("N") };
var started = Stopwatch.GetTimestamp();
await Assert.ThrowsAsync<TimeoutException>(() =>
WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromMilliseconds(400)));
Assert.True(
Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(3),
"Named-pipe connect hung instead of timing out.");
}
[Fact]
public void Handle_cloud_places_does_not_need_a_workbench()
{
var services = new ServiceCollection();
services.AddSingleton<ICloudOverlay>(NullCloudOverlay.Instance);
using var sp = services.BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Cloud.Places" });
Assert.True(reply.Ok);
var places = JsonSerializer.Deserialize<ProviderPlace[]>(reply.Payload ?? "null", WorkbenchIpc.Json);
Assert.NotNull(places);
Assert.Empty(places);
}
[Fact]
public async Task IsListening_does_not_consume_the_server_instance()
{
var options = new WorkbenchIpcOptions { PipeName = "ew-probe-" + Guid.NewGuid().ToString("N") };
using var sp = new ServiceCollection().BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
options,
NullLogger<WorkbenchPipeServer>.Instance);
await server.StartAsync(CancellationToken.None);
try
{
await server.Listening.WaitAsync(TimeSpan.FromSeconds(3));
Assert.True(WorkbenchIpc.IsListening(options.PipeName, 200));
await using var client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(3));
}
finally
{
await server.StopAsync(CancellationToken.None);
}
}
[Fact]
public void Handle_host_shutdown_does_not_need_a_workbench()
{
using var sp = new ServiceCollection().BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var stopped = false;
server.ShutdownRequested = () => stopped = true;
var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Shutdown" });
Assert.True(reply.Ok);
Assert.True(stopped);
}
[Fact]
public void Ping_does_not_need_a_workbench()
{
using var sp = new ServiceCollection().BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
Assert.True(ping.Ok);
}
[Fact]
public async Task Ping_roundtrip_over_a_live_named_pipe()
{
var options = new WorkbenchIpcOptions { PipeName = "ew-live-" + Guid.NewGuid().ToString("N") };
using var sp = new ServiceCollection().BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
options,
NullLogger<WorkbenchPipeServer>.Instance);
await server.StartAsync(CancellationToken.None);
try
{
await server.Listening.WaitAsync(TimeSpan.FromSeconds(3));
await using var client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(3));
}
finally
{
await server.StopAsync(CancellationToken.None);
}
}
private static WorkbenchPipeServer CreateServer(FakeIndexing indexing, FakeTransfers transfers)
{
var services = new ServiceCollection();
services.AddSingleton<IWorkbenchHost>(
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()));
return new WorkbenchPipeServer(
services.BuildServiceProvider(),
new WorkbenchIpcOptions { PipeName = "ew-test" },
NullLogger<WorkbenchPipeServer>.Instance);
}
private sealed class FakeIndexing : IIndexingHost
{
public long FullScanId { get; private set; }
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public void EnqueueFullScan(long sourceId) => FullScanId = sourceId;
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
}
private sealed class FakeTransfers : ITransferHost
{
public bool PausedAll { get; private set; }
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => PausedAll;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() => PausedAll = true;
public void ResumeAll() => PausedAll = false;
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
public string? CopiedDest { get; private set; }
public Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
{
CopiedDest = destinationDirectory;
return Task.CompletedTask;
}
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -11,6 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" /> <ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\..\src\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" /> <ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" /> <ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" /> <ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />

View File

@@ -1,6 +1,7 @@
using Explorer.Application; using Explorer.Application;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing; using Explorer.Indexing;
using Explorer.Storage.Sqlite; using Explorer.Storage.Sqlite;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;

View File

@@ -393,6 +393,8 @@ public class FileRelationTests
RequireGitClean = true, RequireGitClean = true,
DoCompress = true, DoCompress = true,
ArchiveFormat = ArchiveFormat.SevenZip, ArchiveFormat = ArchiveFormat.SevenZip,
DoConvert = true,
ConversionKind = ConversionKind.HeicToJpeg,
DoCopy = false, DoCopy = false,
DoRename = true, DoRename = true,
RenamePrefix = "x_", RenamePrefix = "x_",
@@ -410,6 +412,8 @@ public class FileRelationTests
Assert.True(loaded.RequireGitClean); Assert.True(loaded.RequireGitClean);
Assert.True(loaded.DoCompress); Assert.True(loaded.DoCompress);
Assert.Equal(ArchiveFormat.SevenZip, loaded.ArchiveFormat); Assert.Equal(ArchiveFormat.SevenZip, loaded.ArchiveFormat);
Assert.True(loaded.DoConvert);
Assert.Equal(ConversionKind.HeicToJpeg, loaded.ConversionKind);
Assert.True(loaded.DoRename); Assert.True(loaded.DoRename);
Assert.Equal("x_", loaded.RenamePrefix); Assert.Equal("x_", loaded.RenamePrefix);
Assert.Equal(".git\nbin", loaded.Excludes); Assert.Equal(".git\nbin", loaded.Excludes);
@@ -427,3 +431,60 @@ public class FileRelationTests
Assert.Empty(await store.OperationProfiles.ListAsync()); Assert.Empty(await store.OperationProfiles.ListAsync());
} }
} }
public class IndexStoreLockTests
{
[Fact]
public async Task Second_writer_on_same_path_is_rejected()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
await using var first = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await first.OpenAsync();
var second = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => second.OpenAsync());
Assert.Contains("already in use", ex.Message, StringComparison.OrdinalIgnoreCase);
await first.DisposeAsync();
await second.OpenAsync();
await second.DisposeAsync();
}
[Fact]
public void Mutex_name_is_stable_for_the_same_path()
{
var path = @"C:\Users\me\AppData\Local\ExplorerWorkbench\index.db";
Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path));
Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path));
}
[Fact]
public async Task Read_only_store_opens_while_writer_holds_the_mutex()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
await writer.AddSourceAsync(@"C:\data");
await using var reader = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance, readOnly: true);
await reader.OpenAsync();
Assert.False(reader.CanWrite);
var sources = await reader.Sources.GetAllAsync();
Assert.Single(sources);
Assert.Equal(@"C:\data", sources[0].LastRootPath);
var write = await Assert.ThrowsAsync<InvalidOperationException>(
() => reader.RunWriteAsync(_ => Task.CompletedTask));
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task IsHeld_is_true_while_a_writer_is_open()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
Assert.False(IndexStoreLock.IsHeld(path));
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
Assert.True(await Task.Run(() => IndexStoreLock.IsHeld(path)));
await writer.DisposeAsync();
Assert.False(IndexStoreLock.IsHeld(path));
}
}