Add queued FFmpeg conversion and finish splitting the window from the host.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
Backlog.md
12
Backlog.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<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.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.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" />
|
||||||
|
|||||||
@@ -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 user’s 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()`.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Mental model:
|
|||||||
|
|
||||||
Windows remains the source of truth. Removing a location from Workbench removes Workbench’s 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 Workbench’s 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 vendor’s 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 vendor’s 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 vendor’s 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
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
using Explorer.Hosting;
|
using Explorer.Hosting;
|
||||||
using Explorer.Hosting.Ipc;
|
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;
|
||||||
|
|
||||||
@@ -14,8 +17,10 @@ public partial class App : System.Windows.Application
|
|||||||
private IHost? _host;
|
private IHost? _host;
|
||||||
private WorkbenchPipeClient? _workbenchClient;
|
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) =>
|
||||||
{
|
{
|
||||||
@@ -32,53 +37,80 @@ public partial class App : System.Windows.Application
|
|||||||
retainedFileCountLimit: 14)
|
retainedFileCountLimit: 14)
|
||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
_workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync(
|
var splash = ShowStartupSplash();
|
||||||
TimeSpan.FromSeconds(12),
|
_ = StartWorkbenchAsync(splash);
|
||||||
logger: null).ConfigureAwait(true);
|
}
|
||||||
var hostExe = HostLogonAutostart.FindHostExecutable();
|
|
||||||
if (_workbenchClient is null && hostExe is not null)
|
|
||||||
{
|
|
||||||
MessageBox.Show(
|
|
||||||
"Explorer.Host.exe is present but the window could not connect to it. See logs.",
|
|
||||||
"Explorer Workbench",
|
|
||||||
MessageBoxButton.OK,
|
|
||||||
MessageBoxImage.Error);
|
|
||||||
Shutdown(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_host = Host.CreateDefaultBuilder()
|
private async Task StartWorkbenchAsync(Window splash)
|
||||||
.UseSerilog()
|
{
|
||||||
.ConfigureServices((_, services) =>
|
using var loggerFactory = new SerilogLoggerFactory(Log.Logger);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
if (_workbenchClient is not null)
|
var logger = loggerFactory.CreateLogger("HostConnector");
|
||||||
|
_workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync(
|
||||||
|
TimeSpan.FromSeconds(60),
|
||||||
|
logger)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Could not connect to Explorer.Host.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_workbenchClient is null)
|
||||||
|
{
|
||||||
|
var hostExe = HostLogonAutostart.FindHostExecutable();
|
||||||
|
MessageBox.Show(
|
||||||
|
hostExe is null
|
||||||
|
? "Explorer.Host.exe was not found beside Explorer.App.exe. Copy the host executable next to the window, then start again."
|
||||||
|
: "Explorer.Host.exe did not accept a connection in time. The host process you just started is still initializing; wait until its CPU usage drops, then start Explorer.App.exe again. You do not need to close Explorer.Host.",
|
||||||
|
"Explorer Workbench",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Error);
|
||||||
|
Shutdown(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_host = Host.CreateDefaultBuilder()
|
||||||
|
.UseSerilog()
|
||||||
|
.ConfigureServices((_, services) =>
|
||||||
{
|
{
|
||||||
services.AddExplorerClient(_workbenchClient);
|
services.AddExplorerClient(_workbenchClient);
|
||||||
services.AddExplorerUi();
|
services.AddExplorerUi();
|
||||||
}
|
})
|
||||||
else
|
.Build();
|
||||||
{
|
|
||||||
services.AddExplorer();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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;
|
||||||
window.Show();
|
MainWindow = window;
|
||||||
try
|
window.Show();
|
||||||
{
|
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||||
await vm.InitializeAsync().ConfigureAwait(true);
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await vm.InitializeAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Startup initialization failed");
|
||||||
|
vm.Footer = "Started with errors. See logs.";
|
||||||
|
}
|
||||||
|
|
||||||
|
await _host.StartAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error(ex, "Startup initialization failed");
|
Log.Error(ex, "Could not start Explorer Workbench");
|
||||||
vm.Footer = "Started with errors. See logs.";
|
Shutdown(-1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
splash.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _host.StartAsync().ConfigureAwait(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async void OnExit(ExitEventArgs e)
|
protected override async void OnExit(ExitEventArgs e)
|
||||||
@@ -97,4 +129,27 @@ public partial class App : System.Windows.Application
|
|||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
using Explorer.Hosting;
|
|
||||||
using Explorer.Presentation;
|
using Explorer.Presentation;
|
||||||
using Explorer.Presentation.ViewModels;
|
using Explorer.Presentation.ViewModels;
|
||||||
using Explorer.Windows;
|
using Explorer.Windows;
|
||||||
@@ -11,13 +10,6 @@ namespace Explorer.App;
|
|||||||
|
|
||||||
public static class AppServices
|
public static class AppServices
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddExplorer(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddExplorerCore();
|
|
||||||
services.AddExplorerUi();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddExplorerUi(this IServiceCollection services)
|
public static IServiceCollection AddExplorerUi(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
|
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
|
||||||
|
|||||||
47
src/Explorer.App/ConvertWindow.xaml
Normal file
47
src/Explorer.App/ConvertWindow.xaml
Normal 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>
|
||||||
41
src/Explorer.App/ConvertWindow.xaml.cs
Normal file
41
src/Explorer.App/ConvertWindow.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,25 +22,17 @@
|
|||||||
<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.FileOperations\Explorer.FileOperations.csproj" />
|
|
||||||
<ProjectReference Include="..\Explorer.Host\Explorer.Host.csproj">
|
<ProjectReference Include="..\Explorer.Host\Explorer.Host.csproj">
|
||||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<GlobalPropertiesToRemove>SelfContained;RuntimeIdentifier;PublishSingleFile</GlobalPropertiesToRemove>
|
||||||
</ProjectReference>
|
</ProjectReference>
|
||||||
<ProjectReference Include="..\Explorer.Hosting\Explorer.Hosting.csproj" />
|
<ProjectReference Include="..\Explorer.Hosting.Client\Explorer.Hosting.Client.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.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">
|
<Target Name="CopyExplorerHost" AfterTargets="Build">
|
||||||
@@ -48,12 +40,8 @@
|
|||||||
<_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))</_HostDir>
|
<_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))</_HostDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<_HostFiles Include="$(_HostDir)Explorer.Host.exe" />
|
<_HostFiles Include="$(_HostDir)*.*" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
|
||||||
<_HostFiles Include="$(_HostDir)Explorer.Host.dll" />
|
|
||||||
<_HostFiles Include="$(_HostDir)Explorer.Host.deps.json" />
|
|
||||||
<_HostFiles Include="$(_HostDir)Explorer.Host.runtimeconfig.json" />
|
|
||||||
<_HostFiles Include="$(_HostDir)Explorer.Host.pdb" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
|
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="'@(_HostFiles)' != ''" />
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
|
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
|
||||||
Content="Start Explorer.Host.exe at Windows sign-in"/>
|
Content="Start Explorer.Host.exe at Windows sign-in"/>
|
||||||
<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="Registers a per-user logon task. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write."/>
|
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
|
||||||
|
|
||||||
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
<TextBlock 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"
|
||||||
@@ -87,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>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public partial class SettingsWindow : Window
|
|||||||
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)
|
||||||
@@ -54,7 +55,8 @@ public partial class SettingsWindow : Window
|
|||||||
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);
|
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
|
||||||
@@ -120,6 +122,20 @@ public partial class SettingsWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "FFmpeg executable",
|
||||||
|
Filter = "FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
|
||||||
|
FileName = FfmpegPath.Text
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog(this) == true)
|
||||||
|
{
|
||||||
|
FfmpegPath.Text = dlg.FileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnCancel(object sender, RoutedEventArgs e)
|
private void OnCancel(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_vm.Theme = _originalTheme;
|
_vm.Theme = _originalTheme;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
148
src/Explorer.Application/ConversionPlanner.cs
Normal file
148
src/Explorer.Application/ConversionPlanner.cs
Normal 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)] };
|
||||||
|
}
|
||||||
41
src/Explorer.Application/FfmpegLocator.cs
Normal file
41
src/Explorer.Application/FfmpegLocator.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
20
src/Explorer.Application/ICloudOverlay.cs
Normal file
20
src/Explorer.Application/ICloudOverlay.cs
Normal 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);
|
||||||
|
}
|
||||||
18
src/Explorer.Application/IMediaConversionProvider.cs
Normal file
18
src/Explorer.Application/IMediaConversionProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
55
src/Explorer.Application/IndexStoreLifetime.cs
Normal file
55
src/Explorer.Application/IndexStoreLifetime.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Explorer.Application/NullCloudOverlay.cs
Normal file
32
src/Explorer.Application/NullCloudOverlay.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,6 +26,7 @@ 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,
|
||||||
@@ -26,7 +35,9 @@ public sealed record UiPreferences(
|
|||||||
string? OrganizeArchives = null,
|
string? OrganizeArchives = null,
|
||||||
string? OrganizeDevelopment = null,
|
string? OrganizeDevelopment = null,
|
||||||
bool AutoIndexRemovable = false,
|
bool AutoIndexRemovable = false,
|
||||||
bool BackgroundHostAtLogon = false)
|
bool BackgroundHostAtLogon = false,
|
||||||
|
IReadOnlyList<SessionTabState>? SessionTabs = null,
|
||||||
|
int SessionActiveTab = 0)
|
||||||
{
|
{
|
||||||
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
||||||
}
|
}
|
||||||
@@ -75,8 +86,10 @@ public sealed class UiPreferencesStore
|
|||||||
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
|
"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
|
||||||
@@ -98,6 +111,7 @@ public sealed class UiPreferencesStore
|
|||||||
var backgroundHostAtLogon = 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;
|
||||||
@@ -111,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();
|
||||||
@@ -171,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);
|
||||||
@@ -223,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, autoIndexRemovable, backgroundHostAtLogon);
|
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon, sessionTabs, sessionActiveTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
||||||
@@ -248,6 +285,14 @@ public sealed class UiPreferencesStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> FfmpegLines(UiPreferences preferences)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(preferences.FfmpegPath))
|
||||||
|
{
|
||||||
|
yield return "ffmpeg=" + preferences.FfmpegPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> OrganizeLines(UiPreferences preferences)
|
private static IEnumerable<string> OrganizeLines(UiPreferences preferences)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures))
|
if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures))
|
||||||
@@ -319,6 +364,74 @@ public sealed class UiPreferencesStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> SessionLines(UiPreferences preferences)
|
||||||
|
{
|
||||||
|
var tabs = preferences.SessionTabs;
|
||||||
|
if (tabs is null || tabs.Count == 0)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return "session-active-tab=" + Math.Clamp(preferences.SessionActiveTab, 0, tabs.Count - 1)
|
||||||
|
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
foreach (var tab in tabs.Take(16))
|
||||||
|
{
|
||||||
|
yield return "session-tab=" + FormatSessionTab(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatSessionTab(SessionTabState tab)
|
||||||
|
{
|
||||||
|
var ratio = double.IsFinite(tab.SplitRatio) ? tab.SplitRatio : 0.5;
|
||||||
|
return string.Join(';',
|
||||||
|
tab.IsSplit ? "1" : "0",
|
||||||
|
Format(ratio),
|
||||||
|
tab.ActiveIsRight ? "1" : "0",
|
||||||
|
Uri.EscapeDataString(string.IsNullOrWhiteSpace(tab.LeftPath) ? LocationRoots.ThisPc : tab.LeftPath),
|
||||||
|
Uri.EscapeDataString(tab.RightPath ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SessionTabState? TryParseSessionTab(string value)
|
||||||
|
{
|
||||||
|
var parts = value.Split(';', 5);
|
||||||
|
if (parts.Length < 4)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = Unescape(parts[3]);
|
||||||
|
if (string.IsNullOrWhiteSpace(left))
|
||||||
|
{
|
||||||
|
left = LocationRoots.ThisPc;
|
||||||
|
}
|
||||||
|
|
||||||
|
var right = parts.Length > 4 ? Unescape(parts[4]) : "";
|
||||||
|
var ratio = ParseDouble(parts[1]) ?? 0.5;
|
||||||
|
return new SessionTabState(
|
||||||
|
left,
|
||||||
|
string.IsNullOrWhiteSpace(right) ? null : right,
|
||||||
|
IsTrue(parts[0]) || parts[0] == "1",
|
||||||
|
ratio,
|
||||||
|
IsTrue(parts[2]) || parts[2] == "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unescape(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Uri.UnescapeDataString(value);
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string Format(double value) => value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
|
private static string Format(double value) => value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
private static double? ParseDouble(string value)
|
private static double? ParseDouble(string value)
|
||||||
|
|||||||
8
src/Explorer.Contracts/IHostConnection.cs
Normal file
8
src/Explorer.Contracts/IHostConnection.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Explorer.Contracts;
|
||||||
|
|
||||||
|
public interface IHostConnection
|
||||||
|
{
|
||||||
|
bool IsConnected { get; }
|
||||||
|
event EventHandler<string>? StatusChanged;
|
||||||
|
Task RequestShutdownAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ public interface ITransferHost
|
|||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
|
Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
|
||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
|
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ISourceHost
|
public interface ISourceHost
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
99
src/Explorer.Domain/ConversionFormats.cs
Normal file
99
src/Explorer.Domain/ConversionFormats.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
<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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
21
src/Explorer.FileOperations/FileOperationRegistration.cs
Normal file
21
src/Explorer.FileOperations/FileOperationRegistration.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('.');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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(
|
||||||
@@ -30,7 +31,8 @@ public sealed class OperationProfileService
|
|||||||
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;
|
||||||
@@ -42,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)
|
||||||
@@ -113,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(
|
||||||
@@ -142,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)
|
||||||
@@ -151,9 +163,10 @@ 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 _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
|
await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
@@ -228,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)
|
||||||
@@ -252,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,
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ public sealed class TransferQueue : BackgroundService, ITransferHost
|
|||||||
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;
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0-windows</TargetFramework>
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>Explorer.Host</RootNamespace>
|
<RootNamespace>Explorer.Host</RootNamespace>
|
||||||
<AssemblyName>Explorer.Host</AssemblyName>
|
<AssemblyName>Explorer.Host</AssemblyName>
|
||||||
<ApplicationManifest>..\Explorer.App\app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>..\..\explorer-workbench-icons\explorer-workbench.ico</ApplicationIcon>
|
||||||
|
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||||
|
|||||||
87
src/Explorer.Host/HostEntry.cs
Normal file
87
src/Explorer.Host/HostEntry.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/Explorer.Host/HostTray.cs
Normal file
159
src/Explorer.Host/HostTray.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,25 +1,36 @@
|
|||||||
using Explorer.Hosting;
|
using Explorer.Host;
|
||||||
using Explorer.Windows;
|
using Explorer.Windows;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
var env = new WindowsAppEnvironment();
|
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()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Information()
|
.MinimumLevel.Information()
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(env.LogDirectory, "explorer-host-.log"),
|
Path.Combine(env.LogDirectory, "explorer-host-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
retainedFileCountLimit: 14)
|
retainedFileCountLimit: 14,
|
||||||
|
shared: true)
|
||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
return await HostEntry.RunAsync(args, Boot).ConfigureAwait(false);
|
||||||
builder.Services.AddExplorerHostProcess();
|
|
||||||
using var host = builder.Build();
|
|
||||||
await host.RunAsync().ConfigureAwait(false);
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) when (ex.Message.Contains("already in use", StringComparison.OrdinalIgnoreCase))
|
catch (InvalidOperationException ex) when (ex.Message.Contains("already in use", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
|||||||
14
src/Explorer.Host/app.manifest
Normal file
14
src/Explorer.Host/app.manifest
Normal 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>
|
||||||
26
src/Explorer.Hosting.Client/Explorer.Hosting.Client.csproj
Normal file
26
src/Explorer.Hosting.Client/Explorer.Hosting.Client.csproj
Normal 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>
|
||||||
74
src/Explorer.Hosting.Client/ExplorerHostClientServices.cs
Normal file
74
src/Explorer.Hosting.Client/ExplorerHostClientServices.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/Explorer.Hosting.Client/HostLogonAutostart.cs
Normal file
99
src/Explorer.Hosting.Client/HostLogonAutostart.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
@@ -15,6 +18,10 @@ public static class WorkbenchIpc
|
|||||||
|
|
||||||
public static string DefaultPipeName { get; } = Sanitize("ExplorerWorkbench-" + Environment.UserName);
|
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()
|
public static JsonSerializerOptions Json { get; } = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
@@ -28,9 +35,15 @@ public static class WorkbenchIpc
|
|||||||
var chars = name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
|
var chars = name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
|
||||||
return new string(chars);
|
return new string(chars);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsListening(string pipeName, int timeoutMs = 50)
|
||||||
|
=> WaitNamedPipe(@"\\.\pipe\" + pipeName, (uint)Math.Max(1, timeoutMs));
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern bool WaitNamedPipe(string lpNamedPipeName, uint nTimeOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class IpcEnvelope
|
public sealed class IpcEnvelope
|
||||||
{
|
{
|
||||||
public int V { get; set; } = WorkbenchIpc.ProtocolVersion;
|
public int V { get; set; } = WorkbenchIpc.ProtocolVersion;
|
||||||
public string? Id { get; set; }
|
public string? Id { get; set; }
|
||||||
@@ -1,31 +1,36 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Explorer.Application;
|
||||||
using Explorer.Contracts;
|
using Explorer.Contracts;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
namespace Explorer.Hosting.Ipc;
|
namespace Explorer.Hosting.Ipc;
|
||||||
|
|
||||||
public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly NamedPipeClientStream _pipe;
|
private NamedPipeClientStream _pipe;
|
||||||
private readonly StreamWriter _writer;
|
private StreamWriter _writer;
|
||||||
private readonly StreamReader _reader;
|
private StreamReader _reader;
|
||||||
|
private Task _readLoop;
|
||||||
|
private readonly WorkbenchIpcOptions _options;
|
||||||
private readonly SemaphoreSlim _send = new(1, 1);
|
private readonly SemaphoreSlim _send = new(1, 1);
|
||||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<IpcEnvelope>> _pending = new();
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<IpcEnvelope>> _pending = new();
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private readonly Task _readLoop;
|
|
||||||
private readonly IndexingProxy _indexing;
|
private readonly IndexingProxy _indexing;
|
||||||
private readonly TransferProxy _transfers;
|
private readonly TransferProxy _transfers;
|
||||||
private readonly SourceProxy _sources;
|
private readonly SourceProxy _sources;
|
||||||
private readonly MutationProxy _mutations;
|
private readonly MutationProxy _mutations;
|
||||||
|
private bool _disposed;
|
||||||
|
private bool _suppressRestart;
|
||||||
|
|
||||||
private WorkbenchPipeClient(NamedPipeClientStream pipe)
|
private WorkbenchPipeClient(NamedPipeClientStream pipe, WorkbenchIpcOptions options)
|
||||||
{
|
{
|
||||||
|
_options = options;
|
||||||
_pipe = pipe;
|
_pipe = pipe;
|
||||||
_writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
|
_writer = new StreamWriter(pipe, WorkbenchIpc.Utf8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
|
||||||
_reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
_reader = new StreamReader(pipe, WorkbenchIpc.Utf8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
||||||
_indexing = new IndexingProxy(this);
|
_indexing = new IndexingProxy(this);
|
||||||
_transfers = new TransferProxy(this);
|
_transfers = new TransferProxy(this);
|
||||||
_sources = new SourceProxy(this);
|
_sources = new SourceProxy(this);
|
||||||
@@ -37,6 +42,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
public ITransferHost Transfers => _transfers;
|
public ITransferHost Transfers => _transfers;
|
||||||
public ISourceHost Sources => _sources;
|
public ISourceHost Sources => _sources;
|
||||||
public IIndexMutations Mutations => _mutations;
|
public IIndexMutations Mutations => _mutations;
|
||||||
|
public bool IsConnected => !_disposed && _pipe.IsConnected;
|
||||||
|
public event EventHandler<string>? StatusChanged;
|
||||||
|
|
||||||
public static async Task<WorkbenchPipeClient> ConnectAsync(
|
public static async Task<WorkbenchPipeClient> ConnectAsync(
|
||||||
WorkbenchIpcOptions options,
|
WorkbenchIpcOptions options,
|
||||||
@@ -52,27 +59,53 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
".",
|
".",
|
||||||
options.PipeName,
|
options.PipeName,
|
||||||
PipeDirection.InOut,
|
PipeDirection.InOut,
|
||||||
PipeOptions.Asynchronous);
|
WorkbenchIpc.StreamOptions);
|
||||||
|
WorkbenchPipeClient? client = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var remaining = deadline - DateTime.UtcNow;
|
var remaining = (int)Math.Clamp((deadline - DateTime.UtcNow).TotalMilliseconds, 1, 400);
|
||||||
if (remaining < TimeSpan.FromMilliseconds(50))
|
await ConnectOnceAsync(pipe, options.PipeName, remaining, cancellationToken).ConfigureAwait(false);
|
||||||
|
client = new WorkbenchPipeClient(pipe, options);
|
||||||
|
using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
pingCts.CancelAfter(TimeSpan.FromSeconds(3));
|
||||||
|
try
|
||||||
{
|
{
|
||||||
remaining = TimeSpan.FromMilliseconds(50);
|
await client.CallAsync("Ping", pingCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException($"Named pipe '{options.PipeName}' did not answer Ping.", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
await pipe.ConnectAsync(remaining, cancellationToken).ConfigureAwait(false);
|
|
||||||
var client = new WorkbenchPipeClient(pipe);
|
|
||||||
await client.CallAsync("Ping", cancellationToken).ConfigureAwait(false);
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
last = ex;
|
last = ex;
|
||||||
await pipe.DisposeAsync().ConfigureAwait(false);
|
if (client is not null)
|
||||||
|
{
|
||||||
|
await client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await pipe.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = TimeSpan.FromMilliseconds(80);
|
||||||
|
var left = deadline - DateTime.UtcNow;
|
||||||
|
if (left <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delay > left)
|
||||||
|
{
|
||||||
|
delay = left;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(80, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -85,6 +118,95 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
$"Could not connect to Explorer Workbench host pipe '{options.PipeName}'.", last);
|
$"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)
|
internal IpcEnvelope Call(string op, long? n = null, string? s = null)
|
||||||
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
|
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
|
||||||
|
|
||||||
@@ -97,6 +219,27 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
string[]? paths = null,
|
string[]? paths = null,
|
||||||
bool? flag = null,
|
bool? flag = null,
|
||||||
string? payload = 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 id = Guid.NewGuid().ToString("N");
|
||||||
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -148,6 +291,50 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RequestShutdownAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_suppressRestart = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CallAsync("Host.Shutdown", cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or TimeoutException)
|
||||||
|
{
|
||||||
|
// Host is already stopping.
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusChanged?.Invoke(this, "Background host stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RecycleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_suppressRestart)
|
||||||
|
{
|
||||||
|
throw new IOException("Background host stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusChanged?.Invoke(this, "Host disconnected — reconnecting…");
|
||||||
|
if (!await WorkbenchHostConnector.EnsureHostAsync(TimeSpan.FromSeconds(30), cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
throw new IOException("Explorer.Host.exe is not reachable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pipe = new NamedPipeClientStream(".", _options.PipeName, PipeDirection.InOut, WorkbenchIpc.StreamOptions);
|
||||||
|
await ConnectOnceAsync(pipe, _options.PipeName, 2000, cancellationToken).ConfigureAwait(false);
|
||||||
|
var oldWriter = _writer;
|
||||||
|
var oldReader = _reader;
|
||||||
|
var oldPipe = _pipe;
|
||||||
|
_pipe = pipe;
|
||||||
|
_writer = new StreamWriter(pipe, WorkbenchIpc.Utf8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
|
||||||
|
_reader = new StreamReader(pipe, WorkbenchIpc.Utf8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
||||||
|
_readLoop = ReadLoopAsync(_cts.Token);
|
||||||
|
try { await oldWriter.DisposeAsync().ConfigureAwait(false); } catch { /* old session */ }
|
||||||
|
try { oldReader.Dispose(); } catch { /* old session */ }
|
||||||
|
try { await oldPipe.DisposeAsync().ConfigureAwait(false); } catch { /* old session */ }
|
||||||
|
StatusChanged?.Invoke(this, "Ready · background host connected");
|
||||||
|
}
|
||||||
|
|
||||||
internal void RaiseProgress(ScanProgress progress) => _indexing.Raise(progress);
|
internal void RaiseProgress(ScanProgress progress) => _indexing.Raise(progress);
|
||||||
internal void RaiseChanged() => _transfers.RaiseChanged();
|
internal void RaiseChanged() => _transfers.RaiseChanged();
|
||||||
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
|
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
|
||||||
@@ -197,6 +384,10 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
case "Transfers.JobFinished" when envelope.Job is not null:
|
case "Transfers.JobFinished" when envelope.Job is not null:
|
||||||
RaiseFinished(envelope.Job);
|
RaiseFinished(envelope.Job);
|
||||||
break;
|
break;
|
||||||
|
case "Host.Stopping":
|
||||||
|
_suppressRestart = true;
|
||||||
|
StatusChanged?.Invoke(this, "Background host stopped");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -212,6 +403,13 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
// shutting down
|
// shutting down
|
||||||
}
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
if (!_disposed && !_suppressRestart)
|
||||||
|
{
|
||||||
|
StatusChanged?.Invoke(this, "Host disconnected — reconnecting…");
|
||||||
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
foreach (var tcs in _pending.Values)
|
foreach (var tcs in _pending.Values)
|
||||||
@@ -223,30 +421,17 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
await _cts.CancelAsync().ConfigureAwait(false);
|
await _cts.CancelAsync().ConfigureAwait(false);
|
||||||
try
|
try { await _writer.DisposeAsync().ConfigureAwait(false); } catch { /* pipe already closed */ }
|
||||||
{
|
|
||||||
await _writer.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// pipe already closed
|
|
||||||
}
|
|
||||||
|
|
||||||
_reader.Dispose();
|
_reader.Dispose();
|
||||||
await _pipe.DisposeAsync().ConfigureAwait(false);
|
await _pipe.DisposeAsync().ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _readLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
await _readLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException) { }
|
||||||
{
|
catch (OperationCanceledException) { }
|
||||||
// reader may still be unwinding after the pipe close
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
|
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
_send.Dispose();
|
_send.Dispose();
|
||||||
@@ -256,9 +441,7 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly WorkbenchPipeClient _client;
|
private readonly WorkbenchPipeClient _client;
|
||||||
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
|
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
|
||||||
|
|
||||||
public IndexingProxy(WorkbenchPipeClient client) => _client = client;
|
public IndexingProxy(WorkbenchPipeClient client) => _client = client;
|
||||||
|
|
||||||
public void EnqueueFullScan(long sourceId) => _client.Call("Indexing.EnqueueFullScan", sourceId);
|
public void EnqueueFullScan(long sourceId) => _client.Call("Indexing.EnqueueFullScan", sourceId);
|
||||||
public void EnqueueFolderScan(long sourceId, string pathRel)
|
public void EnqueueFolderScan(long sourceId, string pathRel)
|
||||||
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
|
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
|
||||||
@@ -273,14 +456,9 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
private readonly WorkbenchPipeClient _client;
|
private readonly WorkbenchPipeClient _client;
|
||||||
public event EventHandler? Changed = delegate { };
|
public event EventHandler? Changed = delegate { };
|
||||||
public event EventHandler<TransferJob>? JobFinished = delegate { };
|
public event EventHandler<TransferJob>? JobFinished = delegate { };
|
||||||
|
|
||||||
public TransferProxy(WorkbenchPipeClient client) => _client = client;
|
public TransferProxy(WorkbenchPipeClient client) => _client = client;
|
||||||
|
|
||||||
public bool IsPaused => _client.Call("Transfers.IsPaused").Paused == true;
|
public bool IsPaused => _client.Call("Transfers.IsPaused").Paused == true;
|
||||||
|
public IReadOnlyList<TransferJob> Snapshot() => _client.Call("Transfers.Snapshot").Jobs ?? [];
|
||||||
public IReadOnlyList<TransferJob> Snapshot()
|
|
||||||
=> _client.Call("Transfers.Snapshot").Jobs ?? [];
|
|
||||||
|
|
||||||
public void PauseAll() => _client.Call("Transfers.PauseAll");
|
public void PauseAll() => _client.Call("Transfers.PauseAll");
|
||||||
public void ResumeAll() => _client.Call("Transfers.ResumeAll");
|
public void ResumeAll() => _client.Call("Transfers.ResumeAll");
|
||||||
public void Pause(long jobId) => _client.Call("Transfers.Pause", jobId);
|
public void Pause(long jobId) => _client.Call("Transfers.Pause", jobId);
|
||||||
@@ -309,6 +487,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
=> _client.CallAsync("Transfers.EnqueueAddToArchive", cancellationToken, dest: archivePath, paths: sources.ToArray());
|
=> _client.CallAsync("Transfers.EnqueueAddToArchive", cancellationToken, dest: archivePath, paths: sources.ToArray());
|
||||||
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
|
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
|
||||||
=> _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath);
|
=> _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 RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
|
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
|
||||||
}
|
}
|
||||||
@@ -317,7 +497,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly WorkbenchPipeClient _client;
|
private readonly WorkbenchPipeClient _client;
|
||||||
public SourceProxy(WorkbenchPipeClient client) => _client = client;
|
public SourceProxy(WorkbenchPipeClient client) => _client = client;
|
||||||
|
|
||||||
public Task RefreshAsync(CancellationToken cancellationToken = default)
|
public Task RefreshAsync(CancellationToken cancellationToken = default)
|
||||||
=> _client.CallAsync("Sources.Refresh", cancellationToken);
|
=> _client.CallAsync("Sources.Refresh", cancellationToken);
|
||||||
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
|
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
|
||||||
@@ -327,7 +506,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
|
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
|
||||||
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
|
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
|
||||||
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
|
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
|
||||||
|
|
||||||
private async Task<Source?> CallSource(string op, string path, CancellationToken cancellationToken)
|
private async Task<Source?> CallSource(string op, string path, CancellationToken cancellationToken)
|
||||||
=> (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source;
|
=> (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source;
|
||||||
}
|
}
|
||||||
@@ -336,7 +514,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly WorkbenchPipeClient _client;
|
private readonly WorkbenchPipeClient _client;
|
||||||
public MutationProxy(WorkbenchPipeClient client) => _client = client;
|
public MutationProxy(WorkbenchPipeClient client) => _client = client;
|
||||||
|
|
||||||
public async Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
|
public async Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
|
||||||
=> (await _client.CallAsync("Mutations.UpsertSyncProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0;
|
=> (await _client.CallAsync("Mutations.UpsertSyncProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0;
|
||||||
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default)
|
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default)
|
||||||
@@ -353,7 +530,6 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
|
|||||||
=> _client.CallAsync("Mutations.EnqueueHashCollisions", cancellationToken, n: sourceId ?? 0);
|
=> _client.CallAsync("Mutations.EnqueueHashCollisions", cancellationToken, n: sourceId ?? 0);
|
||||||
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
|
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
|
||||||
=> _client.CallAsync("Mutations.UpsertRelation", cancellationToken, payload: Json(relation));
|
=> _client.CallAsync("Mutations.UpsertRelation", cancellationToken, payload: Json(relation));
|
||||||
|
|
||||||
private static string Json<T>(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json);
|
private static string Json<T>(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
130
src/Explorer.Hosting.Client/WorkbenchHostConnector.cs
Normal file
130
src/Explorer.Hosting.Client/WorkbenchHostConnector.cs
Normal 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);
|
||||||
|
}
|
||||||
17
src/Explorer.Hosting/DeferredServiceProvider.cs
Normal file
17
src/Explorer.Hosting/DeferredServiceProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
|
||||||
<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.Contracts\Explorer.Contracts.csproj" />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Explorer.Contracts;
|
|||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
using Explorer.FileOperations;
|
using Explorer.FileOperations;
|
||||||
using Explorer.Hosting.Ipc;
|
|
||||||
using Explorer.Indexing;
|
using Explorer.Indexing;
|
||||||
using Explorer.Plugin.Abstractions;
|
using Explorer.Plugin.Abstractions;
|
||||||
using Explorer.Plugin.GoogleDrive;
|
using Explorer.Plugin.GoogleDrive;
|
||||||
@@ -24,13 +23,13 @@ public static class ExplorerHostServices
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddExplorerCore(this IServiceCollection services)
|
public static IServiceCollection AddExplorerCore(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddExplorerShared(readOnly: false);
|
services.AddExplorerHostRuntime();
|
||||||
services.AddExplorerWorkers();
|
services.AddExplorerWorkers();
|
||||||
services.AddExplorerOperations();
|
services.AddExplorerOperations();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddExplorerShared(this IServiceCollection services, bool readOnly)
|
public static IServiceCollection AddExplorerHostRuntime(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.TryAddSingleton<IClock, SystemClock>();
|
services.TryAddSingleton<IClock, SystemClock>();
|
||||||
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
|
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
|
||||||
@@ -42,14 +41,16 @@ public static class ExplorerHostServices
|
|||||||
{
|
{
|
||||||
var env = sp.GetRequiredService<IAppEnvironment>();
|
var env = sp.GetRequiredService<IAppEnvironment>();
|
||||||
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
||||||
return new SqliteIndexStore(env.DatabasePath, logger, readOnly);
|
return new SqliteIndexStore(env.DatabasePath, logger, readOnly: false);
|
||||||
});
|
});
|
||||||
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
|
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
|
||||||
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
|
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
|
||||||
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
|
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
|
||||||
services.AddSingleton<StorageProviderRegistry>();
|
services.AddSingleton<StorageProviderRegistry>();
|
||||||
|
services.AddSingleton<ICloudOverlay>(sp => sp.GetRequiredService<StorageProviderRegistry>());
|
||||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
||||||
|
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
||||||
services.AddSingleton<WindowsGitStatusProvider>();
|
services.AddSingleton<WindowsGitStatusProvider>();
|
||||||
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||||
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||||
@@ -101,38 +102,11 @@ public static class ExplorerHostServices
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddExplorerOperations(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddSingleton<FileOperationService>();
|
|
||||||
services.AddSingleton<RenameBatchService>();
|
|
||||||
services.AddSingleton<FolderSyncPlanner>();
|
|
||||||
services.AddSingleton<FolderSyncService>();
|
|
||||||
services.AddSingleton<FileOperationProfilePlanner>();
|
|
||||||
services.AddSingleton<OperationProfileService>();
|
|
||||||
services.AddSingleton<ReorganizePlanner>();
|
|
||||||
services.AddSingleton<ReorganizeService>();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddExplorerClient(this IServiceCollection services, IWorkbenchHost workbench)
|
|
||||||
{
|
|
||||||
services.AddSingleton(workbench);
|
|
||||||
services.AddSingleton(workbench.Indexing);
|
|
||||||
services.AddSingleton(workbench.Transfers);
|
|
||||||
services.AddSingleton(workbench.Sources);
|
|
||||||
services.AddSingleton(workbench.Mutations);
|
|
||||||
services.AddExplorerShared(readOnly: true);
|
|
||||||
services.AddExplorerOperations();
|
|
||||||
services.AddHostedService<IndexStoreLifetime>();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
|
public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.TryAddSingleton<WorkbenchIpcOptions>();
|
services.TryAddSingleton<Explorer.Hosting.Ipc.WorkbenchIpcOptions>();
|
||||||
services.AddHostedService<IndexStoreLifetime>();
|
services.AddHostedService<IndexStoreLifetime>();
|
||||||
services.AddExplorerCore();
|
services.AddExplorerCore();
|
||||||
services.AddHostedService<WorkbenchPipeServer>();
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace Explorer.Hosting;
|
|
||||||
|
|
||||||
public static class HostLogonAutostart
|
|
||||||
{
|
|
||||||
public const string TaskName = "ExplorerWorkbenchHost";
|
|
||||||
|
|
||||||
public static string? FindHostExecutable()
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(AppContext.BaseDirectory, "Explorer.Host.exe");
|
|
||||||
return File.Exists(candidate) ? candidate : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string[] CreateTaskArgs(string hostExePath)
|
|
||||||
=>
|
|
||||||
[
|
|
||||||
"/Create",
|
|
||||||
"/TN",
|
|
||||||
TaskName,
|
|
||||||
"/TR",
|
|
||||||
Quote(hostExePath),
|
|
||||||
"/SC",
|
|
||||||
"ONLOGON",
|
|
||||||
"/F",
|
|
||||||
"/RL",
|
|
||||||
"LIMITED"
|
|
||||||
];
|
|
||||||
|
|
||||||
public static string[] DeleteTaskArgs() => ["/Delete", "/TN", TaskName, "/F"];
|
|
||||||
|
|
||||||
public static bool TryRegister(string hostExePath, out string error)
|
|
||||||
{
|
|
||||||
if (!File.Exists(hostExePath))
|
|
||||||
{
|
|
||||||
error = "Explorer.Host.exe was not found next to Explorer Workbench.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Run(CreateTaskArgs(hostExePath), out error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryUnregister(out string error) => Run(DeleteTaskArgs(), out error);
|
|
||||||
|
|
||||||
private static string Quote(string path) => "\"" + path + "\"";
|
|
||||||
|
|
||||||
private static bool Run(string[] args, out string error)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "schtasks.exe",
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
foreach (var arg in args)
|
|
||||||
{
|
|
||||||
process.StartInfo.ArgumentList.Add(arg);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
if (!process.WaitForExit(8000))
|
|
||||||
{
|
|
||||||
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
|
||||||
error = "Timed out updating the sign-in task.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var stderr = process.StandardError.ReadToEnd();
|
|
||||||
var stdout = process.StandardOutput.ReadToEnd();
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
|
||||||
if (IsAlreadyAbsent(args, error))
|
|
||||||
{
|
|
||||||
error = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(error))
|
|
||||||
{
|
|
||||||
error = "schtasks exited with code " + process.ExitCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
error = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
error = ex.Message;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsAlreadyAbsent(string[] args, string error)
|
|
||||||
=> args.Length > 0
|
|
||||||
&& args[0].Equals("/Delete", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& (error.Contains("cannot find", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| error.Contains("not found", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using Explorer.Application;
|
|
||||||
using Explorer.Domain.Abstractions;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
|
|
||||||
namespace Explorer.Hosting;
|
|
||||||
|
|
||||||
public sealed class IndexStoreLifetime : IHostedService
|
|
||||||
{
|
|
||||||
private readonly IIndexStore _store;
|
|
||||||
private readonly SourceManager _sources;
|
|
||||||
|
|
||||||
public IndexStoreLifetime(IIndexStore store, SourceManager sources)
|
|
||||||
{
|
|
||||||
_store = store;
|
|
||||||
_sources = sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
await _sources.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync();
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Explorer.Application;
|
||||||
using Explorer.Contracts;
|
using Explorer.Contracts;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -10,49 +13,126 @@ namespace Explorer.Hosting.Ipc;
|
|||||||
|
|
||||||
public sealed class WorkbenchPipeServer : BackgroundService
|
public sealed class WorkbenchPipeServer : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IWorkbenchHost _workbench;
|
private readonly IServiceProvider _services;
|
||||||
private readonly WorkbenchIpcOptions _options;
|
private readonly WorkbenchIpcOptions _options;
|
||||||
private readonly ILogger<WorkbenchPipeServer> _logger;
|
private readonly ILogger<WorkbenchPipeServer> _logger;
|
||||||
private readonly SemaphoreSlim _write = new(1, 1);
|
private readonly SemaphoreSlim _write = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _ensureWorkbench = new(1, 1);
|
||||||
private readonly TaskCompletionSource _listening = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
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(
|
public WorkbenchPipeServer(
|
||||||
IWorkbenchHost workbench,
|
IServiceProvider services,
|
||||||
WorkbenchIpcOptions options,
|
WorkbenchIpcOptions options,
|
||||||
ILogger<WorkbenchPipeServer> logger)
|
ILogger<WorkbenchPipeServer> logger)
|
||||||
{
|
{
|
||||||
_workbench = workbench;
|
_services = services;
|
||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Listening => _listening.Task;
|
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)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Listening on named pipe {Pipe} protocol v{Version}", _options.PipeName, WorkbenchIpc.ProtocolVersion);
|
_logger.LogInformation("Listening on named pipe {Pipe} protocol v{Version}", _options.PipeName, WorkbenchIpc.ProtocolVersion);
|
||||||
|
var sessions = new List<Task>();
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
sessions.RemoveAll(t => t.IsCompleted);
|
||||||
var server = new NamedPipeServerStream(
|
var server = new NamedPipeServerStream(
|
||||||
_options.PipeName,
|
_options.PipeName,
|
||||||
PipeDirection.InOut,
|
PipeDirection.InOut,
|
||||||
1,
|
4,
|
||||||
PipeTransmissionMode.Byte,
|
PipeTransmissionMode.Byte,
|
||||||
PipeOptions.Asynchronous);
|
WorkbenchIpc.StreamOptions);
|
||||||
await using (server.ConfigureAwait(false))
|
_listening.TrySetResult();
|
||||||
|
using var cancelPipe = stoppingToken.Register(() =>
|
||||||
{
|
{
|
||||||
_listening.TrySetResult();
|
try { server.Dispose(); }
|
||||||
using var cancelPipe = stoppingToken.Register(() =>
|
catch (ObjectDisposedException) { }
|
||||||
{
|
catch (IOException) { }
|
||||||
try { server.Dispose(); }
|
});
|
||||||
catch (ObjectDisposedException) { }
|
await server.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
|
||||||
catch (IOException) { }
|
_logger.LogInformation("Window connected on named pipe {Pipe}", _options.PipeName);
|
||||||
});
|
sessions.Add(ServeSessionAsync(server, stoppingToken));
|
||||||
await server.WaitForConnectionAsync(stoppingToken).ConfigureAwait(false);
|
|
||||||
await ServeAsync(server, stoppingToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
|
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -68,7 +148,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Workbench pipe session ended");
|
_logger.LogWarning(ex, "Workbench pipe accept failed");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(250, stoppingToken).ConfigureAwait(false);
|
await Task.Delay(250, stoppingToken).ConfigureAwait(false);
|
||||||
@@ -79,12 +159,37 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(sessions).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Pipe session unwind");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ServeSessionAsync(NamedPipeServerStream server, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (server.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await ServeAsync(server, stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Workbench pipe session ended");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ServeAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
|
private async Task ServeAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
using var reader = new StreamReader(pipe, WorkbenchIpc.Utf8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
||||||
await using var writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
|
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)
|
void OnProgress(object? sender, ScanProgress progress)
|
||||||
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Indexing.Progress", Progress = progress }, stoppingToken);
|
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Indexing.Progress", Progress = progress }, stoppingToken);
|
||||||
@@ -95,9 +200,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
void OnFinished(object? sender, TransferJob job)
|
void OnFinished(object? sender, TransferJob job)
|
||||||
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
|
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
|
||||||
|
|
||||||
_workbench.Indexing.ProgressChanged += OnProgress;
|
var hooked = false;
|
||||||
_workbench.Transfers.Changed += OnChanged;
|
|
||||||
_workbench.Transfers.JobFinished += OnFinished;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
@@ -126,15 +229,28 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
continue;
|
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);
|
var response = await HandleAsync(request).ConfigureAwait(false);
|
||||||
await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false);
|
await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_workbench.Indexing.ProgressChanged -= OnProgress;
|
_writers.TryRemove(writer, out _);
|
||||||
_workbench.Transfers.Changed -= OnChanged;
|
if (hooked && _workbench is not null)
|
||||||
_workbench.Transfers.JobFinished -= OnFinished;
|
{
|
||||||
|
Workbench.Indexing.ProgressChanged -= OnProgress;
|
||||||
|
Workbench.Transfers.Changed -= OnChanged;
|
||||||
|
Workbench.Transfers.JobFinished -= OnFinished;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,121 +269,172 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (NeedsWorkbench(request.Op))
|
||||||
|
{
|
||||||
|
await EnsureWorkbenchAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Op?.StartsWith("Cloud.", StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
await EnsureOverlayAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
switch (request.Op)
|
switch (request.Op)
|
||||||
{
|
{
|
||||||
case "Ping":
|
case "Ping":
|
||||||
return reply;
|
return reply;
|
||||||
|
case "Host.Shutdown":
|
||||||
|
await RequestShutdownAsync().ConfigureAwait(false);
|
||||||
|
return reply;
|
||||||
case "Indexing.EnqueueFullScan":
|
case "Indexing.EnqueueFullScan":
|
||||||
_workbench.Indexing.EnqueueFullScan(request.N ?? 0);
|
Workbench.Indexing.EnqueueFullScan(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Indexing.EnqueueFolderScan":
|
case "Indexing.EnqueueFolderScan":
|
||||||
_workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
|
Workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
|
||||||
return reply;
|
return reply;
|
||||||
case "Indexing.EnqueueReconcile":
|
case "Indexing.EnqueueReconcile":
|
||||||
_workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
|
Workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
|
||||||
return reply;
|
return reply;
|
||||||
case "Indexing.Cancel":
|
case "Indexing.Cancel":
|
||||||
_workbench.Indexing.Cancel(request.N ?? 0);
|
Workbench.Indexing.Cancel(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.Snapshot":
|
case "Transfers.Snapshot":
|
||||||
reply.Jobs = _workbench.Transfers.Snapshot().ToArray();
|
reply.Jobs = Workbench.Transfers.Snapshot().ToArray();
|
||||||
reply.Paused = _workbench.Transfers.IsPaused;
|
reply.Paused = Workbench.Transfers.IsPaused;
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.IsPaused":
|
case "Transfers.IsPaused":
|
||||||
reply.Paused = _workbench.Transfers.IsPaused;
|
reply.Paused = Workbench.Transfers.IsPaused;
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.PauseAll":
|
case "Transfers.PauseAll":
|
||||||
_workbench.Transfers.PauseAll();
|
Workbench.Transfers.PauseAll();
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.ResumeAll":
|
case "Transfers.ResumeAll":
|
||||||
_workbench.Transfers.ResumeAll();
|
Workbench.Transfers.ResumeAll();
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.Pause":
|
case "Transfers.Pause":
|
||||||
_workbench.Transfers.Pause(request.N ?? 0);
|
Workbench.Transfers.Pause(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.Resume":
|
case "Transfers.Resume":
|
||||||
_workbench.Transfers.Resume(request.N ?? 0);
|
Workbench.Transfers.Resume(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.Retry":
|
case "Transfers.Retry":
|
||||||
_workbench.Transfers.Retry(request.N ?? 0);
|
Workbench.Transfers.Retry(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.Cancel":
|
case "Transfers.Cancel":
|
||||||
_workbench.Transfers.Cancel(request.N ?? 0);
|
Workbench.Transfers.Cancel(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.Dismiss":
|
case "Transfers.Dismiss":
|
||||||
_workbench.Transfers.Dismiss(request.N ?? 0);
|
Workbench.Transfers.Dismiss(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.ClearFinished":
|
case "Transfers.ClearFinished":
|
||||||
_workbench.Transfers.ClearFinished();
|
Workbench.Transfers.ClearFinished();
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.MoveUp":
|
case "Transfers.MoveUp":
|
||||||
reply.Flag = _workbench.Transfers.MoveUp(request.N ?? 0);
|
reply.Flag = Workbench.Transfers.MoveUp(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.MoveDown":
|
case "Transfers.MoveDown":
|
||||||
reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0);
|
reply.Flag = Workbench.Transfers.MoveDown(request.N ?? 0);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueCopy":
|
case "Transfers.EnqueueCopy":
|
||||||
await _workbench.Transfers.EnqueueCopyAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueCopyAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueMove":
|
case "Transfers.EnqueueMove":
|
||||||
await _workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueDelete":
|
case "Transfers.EnqueueDelete":
|
||||||
await _workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueRename":
|
case "Transfers.EnqueueRename":
|
||||||
await _workbench.Transfers.EnqueueRenameAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueRenameAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueEmptyRecycleBin":
|
case "Transfers.EnqueueEmptyRecycleBin":
|
||||||
await _workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueExtract":
|
case "Transfers.EnqueueExtract":
|
||||||
await _workbench.Transfers.EnqueueExtractAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueExtractAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueCompress":
|
case "Transfers.EnqueueCompress":
|
||||||
await _workbench.Transfers.EnqueueCompressAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueCompressAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueAddToArchive":
|
case "Transfers.EnqueueAddToArchive":
|
||||||
await _workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Transfers.EnqueueVerifyArchive":
|
case "Transfers.EnqueueVerifyArchive":
|
||||||
await _workbench.Transfers.EnqueueVerifyArchiveAsync(request.S ?? "").ConfigureAwait(false);
|
await Workbench.Transfers.EnqueueVerifyArchiveAsync(request.S ?? "").ConfigureAwait(false);
|
||||||
|
return reply;
|
||||||
|
case "Transfers.EnqueueConvert":
|
||||||
|
var convertKind = Enum.TryParse<ConversionKind>(request.S, true, out var kind)
|
||||||
|
? kind
|
||||||
|
: ConversionFormats.Infer(request.Paths is { Length: > 0 } p ? p[0] : "", request.Dest ?? "");
|
||||||
|
var convertSource = request.Paths is { Length: > 0 } paths ? paths[0] : "";
|
||||||
|
await Workbench.Transfers.EnqueueConvertAsync(convertSource, request.Dest ?? "", convertKind).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Sources.Refresh":
|
case "Sources.Refresh":
|
||||||
await _workbench.Sources.RefreshAsync().ConfigureAwait(false);
|
await Workbench.Sources.RefreshAsync().ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Sources.AddUnc":
|
case "Sources.AddUnc":
|
||||||
reply.Source = await _workbench.Sources.AddUncAsync(request.S ?? "").ConfigureAwait(false);
|
reply.Source = await Workbench.Sources.AddUncAsync(request.S ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Sources.EnsureForPath":
|
case "Sources.EnsureForPath":
|
||||||
reply.Source = await _workbench.Sources.EnsureForPathAsync(request.S ?? "").ConfigureAwait(false);
|
reply.Source = await Workbench.Sources.EnsureForPathAsync(request.S ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Sources.Forget":
|
case "Sources.Forget":
|
||||||
reply.Flag = await _workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
|
reply.Flag = await Workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.UpsertSyncProfile":
|
case "Mutations.UpsertSyncProfile":
|
||||||
reply.N = await _workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
|
reply.N = await Workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.DeleteSyncProfile":
|
case "Mutations.DeleteSyncProfile":
|
||||||
await _workbench.Mutations.DeleteSyncProfileAsync(request.N ?? 0).ConfigureAwait(false);
|
await Workbench.Mutations.DeleteSyncProfileAsync(request.N ?? 0).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.UpsertOperationProfile":
|
case "Mutations.UpsertOperationProfile":
|
||||||
reply.N = await _workbench.Mutations.UpsertOperationProfileAsync(Read<OperationProfile>(request.Payload)).ConfigureAwait(false);
|
reply.N = await Workbench.Mutations.UpsertOperationProfileAsync(Read<OperationProfile>(request.Payload)).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.DeleteOperationProfile":
|
case "Mutations.DeleteOperationProfile":
|
||||||
await _workbench.Mutations.DeleteOperationProfileAsync(request.N ?? 0).ConfigureAwait(false);
|
await Workbench.Mutations.DeleteOperationProfileAsync(request.N ?? 0).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.CreateRenameBatch":
|
case "Mutations.CreateRenameBatch":
|
||||||
reply.N = await _workbench.Mutations.CreateRenameBatchAsync(Read<RenameBatchItem[]>(request.Payload) ?? []).ConfigureAwait(false);
|
reply.N = await Workbench.Mutations.CreateRenameBatchAsync(Read<RenameBatchItem[]>(request.Payload) ?? []).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.MarkRenameBatchUndone":
|
case "Mutations.MarkRenameBatchUndone":
|
||||||
await _workbench.Mutations.MarkRenameBatchUndoneAsync(request.N ?? 0).ConfigureAwait(false);
|
await Workbench.Mutations.MarkRenameBatchUndoneAsync(request.N ?? 0).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.EnqueueHashCollisions":
|
case "Mutations.EnqueueHashCollisions":
|
||||||
await _workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
|
await Workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
|
||||||
return reply;
|
return reply;
|
||||||
case "Mutations.UpsertRelation":
|
case "Mutations.UpsertRelation":
|
||||||
await _workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);
|
await Workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);
|
||||||
|
return reply;
|
||||||
|
case "Cloud.Places":
|
||||||
|
reply.Payload = JsonSerializer.Serialize(_overlay!.GetPlaces(), WorkbenchIpc.Json);
|
||||||
|
return reply;
|
||||||
|
case "Cloud.FindProviderId":
|
||||||
|
reply.S = _overlay!.FindProviderId(request.S ?? "");
|
||||||
|
return reply;
|
||||||
|
case "Cloud.HasCapability":
|
||||||
|
reply.Flag = _overlay!.HasCapability(request.S ?? "", (ProviderCapability)(request.N ?? 0));
|
||||||
|
return reply;
|
||||||
|
case "Cloud.Enrich":
|
||||||
|
reply.Payload = JsonSerializer.Serialize(
|
||||||
|
await _overlay!.EnrichAsync(Read<FileSystemItem[]>(request.Payload) ?? [], CancellationToken.None)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
WorkbenchIpc.Json);
|
||||||
|
return reply;
|
||||||
|
case "Cloud.Invoke":
|
||||||
|
reply.Payload = JsonSerializer.Serialize(
|
||||||
|
await _overlay!.InvokeAsync((ProviderAction)(request.N ?? 0), request.Paths ?? [], CancellationToken.None)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
WorkbenchIpc.Json);
|
||||||
|
return reply;
|
||||||
|
case "Cloud.State":
|
||||||
|
reply.Payload = JsonSerializer.Serialize(
|
||||||
|
await _overlay!.GetStateAsync(request.S ?? "", CancellationToken.None).ConfigureAwait(false),
|
||||||
|
WorkbenchIpc.Json);
|
||||||
|
return reply;
|
||||||
|
case "Cloud.Quota":
|
||||||
|
reply.Payload = JsonSerializer.Serialize(
|
||||||
|
await _overlay!.TryGetQuotaAsync(request.S ?? "", CancellationToken.None).ConfigureAwait(false),
|
||||||
|
WorkbenchIpc.Json);
|
||||||
return reply;
|
return reply;
|
||||||
default:
|
default:
|
||||||
reply.Ok = false;
|
reply.Ok = false;
|
||||||
@@ -283,6 +450,40 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool NeedsWorkbench(string? op)
|
||||||
|
=> op is not null and not "Ping" and not "Host.Shutdown"
|
||||||
|
&& !op.StartsWith("Cloud.", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
internal async Task RequestShutdownAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _shutdownOnce, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope = new IpcEnvelope { Evt = "Host.Stopping" };
|
||||||
|
foreach (var writer in _writers.Keys)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WriteAsync(writer, envelope, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// session already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ShutdownRequested?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Host shutdown callback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static T Read<T>(string? payload)
|
private static T Read<T>(string? payload)
|
||||||
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
|
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
|
||||||
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);
|
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Explorer.Hosting.Ipc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Explorer.Hosting;
|
|
||||||
|
|
||||||
public static class WorkbenchHostConnector
|
|
||||||
{
|
|
||||||
public static async Task<WorkbenchPipeClient?> ConnectOrStartAsync(
|
|
||||||
TimeSpan timeout,
|
|
||||||
ILogger? logger = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var options = new WorkbenchIpcOptions();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(1), cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
logger?.LogDebug(ex, "Background host was not listening yet");
|
|
||||||
}
|
|
||||||
|
|
||||||
var exe = HostLogonAutostart.FindHostExecutable();
|
|
||||||
if (exe is null)
|
|
||||||
{
|
|
||||||
logger?.LogInformation("Explorer.Host.exe is not beside the window; using in-process core");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = exe,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
WorkingDirectory = Path.GetDirectoryName(exe)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger?.LogWarning(ex, "Could not start Explorer.Host.exe");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await WorkbenchPipeClient.ConnectAsync(options, timeout, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
<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" />
|
||||||
|
|||||||
108
src/Explorer.Presentation/ViewModels/ConvertViewModel.cs
Normal file
108
src/Explorer.Presentation/ViewModels/ConvertViewModel.cs
Normal 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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IIndexingHost _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;
|
||||||
@@ -70,7 +76,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IWorkbenchHost workbench,
|
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,
|
||||||
@@ -83,7 +89,11 @@ 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;
|
||||||
@@ -102,7 +112,26 @@ 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 = [];
|
||||||
@@ -168,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;
|
||||||
@@ -187,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);
|
||||||
@@ -479,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());
|
||||||
|
|
||||||
@@ -667,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();
|
||||||
@@ -984,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);
|
||||||
@@ -1045,6 +1121,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
public void SaveLayout(double width, double height, double left, double top, bool maximized, double treeWidth)
|
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),
|
||||||
@@ -1053,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) };
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,27 @@ public sealed class IndexStoreLock : IDisposable
|
|||||||
return @"Local\ExplorerWorkbench-Index-" + hash;
|
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)
|
public static IndexStoreLock Acquire(string databasePath, TimeSpan? timeout = null)
|
||||||
{
|
{
|
||||||
var name = MutexNameFor(databasePath);
|
var name = MutexNameFor(databasePath);
|
||||||
|
|||||||
@@ -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; } = "";
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -560,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 '',
|
||||||
@@ -581,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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
247
src/Explorer.Windows/FfmpegConversionExecutor.cs
Normal file
247
src/Explorer.Windows/FfmpegConversionExecutor.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/Explorer.Application.Tests/ConversionPlannerTests.cs
Normal file
109
tests/Explorer.Application.Tests/ConversionPlannerTests.cs
Normal 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();
|
||||||
|
}
|
||||||
30
tests/Explorer.Application.Tests/FfmpegLocatorTests.cs
Normal file
30
tests/Explorer.Application.Tests/FfmpegLocatorTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 @@ 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]
|
[Fact]
|
||||||
public void Parse_reads_host_and_removable_index_flags()
|
public void Parse_reads_host_and_removable_index_flags()
|
||||||
{
|
{
|
||||||
@@ -70,6 +78,8 @@ public class UiPreferencesStoreTests
|
|||||||
Assert.False(prefs.BackgroundHostAtLogon);
|
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]
|
||||||
@@ -91,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]
|
||||||
@@ -100,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);
|
||||||
@@ -114,6 +171,13 @@ public class UiPreferencesStoreTests
|
|||||||
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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -119,7 +134,8 @@ public class OperationProfileServiceTests
|
|||||||
enumerator,
|
enumerator,
|
||||||
git,
|
git,
|
||||||
new NeverHydrate(),
|
new NeverHydrate(),
|
||||||
new FakeArchiveExecutor());
|
new FakeArchiveExecutor(),
|
||||||
|
new FakeConversionExecutor());
|
||||||
return new ProfileHarness
|
return new ProfileHarness
|
||||||
{
|
{
|
||||||
Profiles = profiles,
|
Profiles = profiles,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
using Explorer.Analysis;
|
||||||
|
using Explorer.Application;
|
||||||
using Explorer.Contracts;
|
using Explorer.Contracts;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
using Explorer.Hosting;
|
using Explorer.Hosting;
|
||||||
|
using Explorer.Indexing;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Search;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
@@ -26,6 +31,10 @@ public class CoreRegistrationTests
|
|||||||
Assert.NotNull(sp.GetService<ITransferHost>());
|
Assert.NotNull(sp.GetService<ITransferHost>());
|
||||||
Assert.NotNull(sp.GetService<IIndexMutations>());
|
Assert.NotNull(sp.GetService<IIndexMutations>());
|
||||||
Assert.Null(sp.GetService<IOsClipboard>());
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -52,9 +61,22 @@ public class CoreRegistrationTests
|
|||||||
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
|
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
|
||||||
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
|
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
|
||||||
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
|
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();
|
var hosted = sp.GetServices<IHostedService>().ToList();
|
||||||
Assert.Contains(hosted, s => s is IndexStoreLifetime);
|
Assert.Contains(hosted, s => s is IndexStoreLifetime);
|
||||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService");
|
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Contracts\Explorer.Contracts.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Contracts\Explorer.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.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" />
|
<ProjectReference Include="..\..\src\Explorer.Hosting\Explorer.Hosting.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ namespace Explorer.Hosting.Tests;
|
|||||||
public class HostLogonAutostartTests
|
public class HostLogonAutostartTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_args_are_per_user_logon_not_system_service()
|
public void Run_command_is_the_quoted_host_exe_for_the_current_user()
|
||||||
{
|
{
|
||||||
var args = HostLogonAutostart.CreateTaskArgs(@"C:\Tools\Explorer.Host.exe");
|
var command = HostLogonAutostart.RunCommand(@"C:\Tools\Explorer.Host.exe");
|
||||||
Assert.Contains("/SC", args);
|
Assert.Equal("\"C:\\Tools\\Explorer.Host.exe\"", command);
|
||||||
Assert.Contains("ONLOGON", args);
|
Assert.Equal("ExplorerWorkbenchHost", HostLogonAutostart.RunValueName);
|
||||||
Assert.Contains("/RL", args);
|
Assert.DoesNotContain("/RU", command, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("LIMITED", args);
|
Assert.DoesNotContain("ONSTART", command, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains(HostLogonAutostart.TaskName, args);
|
|
||||||
Assert.DoesNotContain("ONSTART", args);
|
|
||||||
Assert.DoesNotContain("/RU", args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
tests/Explorer.Hosting.Tests/ProjectGraphTests.cs
Normal file
67
tests/Explorer.Hosting.Tests/ProjectGraphTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Contracts;
|
using Explorer.Contracts;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Hosting.Ipc;
|
using Explorer.Hosting.Ipc;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
namespace Explorer.Hosting.Tests;
|
namespace Explorer.Hosting.Tests;
|
||||||
@@ -13,10 +17,7 @@ public class WorkbenchPipeTests
|
|||||||
{
|
{
|
||||||
var indexing = new FakeIndexing();
|
var indexing = new FakeIndexing();
|
||||||
var transfers = new FakeTransfers();
|
var transfers = new FakeTransfers();
|
||||||
var server = new WorkbenchPipeServer(
|
var server = CreateServer(indexing, transfers);
|
||||||
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()),
|
|
||||||
new WorkbenchIpcOptions { PipeName = "ew-test" },
|
|
||||||
NullLogger<WorkbenchPipeServer>.Instance);
|
|
||||||
|
|
||||||
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
|
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
|
||||||
Assert.True(ping.Ok);
|
Assert.True(ping.Ok);
|
||||||
@@ -46,15 +47,122 @@ public class WorkbenchPipeTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Handle_rejects_other_protocol_versions()
|
public void Handle_rejects_other_protocol_versions()
|
||||||
{
|
{
|
||||||
var server = new WorkbenchPipeServer(
|
var server = CreateServer(new FakeIndexing(), new FakeTransfers());
|
||||||
new WorkbenchHost(new FakeIndexing(), new FakeTransfers(), new StubSources(), new StubMutations()),
|
|
||||||
new WorkbenchIpcOptions(),
|
|
||||||
NullLogger<WorkbenchPipeServer>.Instance);
|
|
||||||
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
|
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
|
||||||
Assert.False(reply.Ok);
|
Assert.False(reply.Ok);
|
||||||
Assert.Contains("99", reply.Error);
|
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
|
private sealed class FakeIndexing : IIndexingHost
|
||||||
{
|
{
|
||||||
public long FullScanId { get; private set; }
|
public long FullScanId { get; private set; }
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -471,4 +475,16 @@ public class IndexStoreLockTests
|
|||||||
() => reader.RunWriteAsync(_ => Task.CompletedTask));
|
() => reader.RunWriteAsync(_ => Task.CompletedTask));
|
||||||
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user