From 0c6a3ceac23ca00466f329ae9aebd0ef7e2e1d0e Mon Sep 17 00:00:00 2001 From: stuartd Date: Wed, 7 Oct 2026 22:17:02 +0100 Subject: [PATCH 1/3] Address release, comparison and session reliability findings --- .github/workflows/release.yml | 49 - .github/workflows/windows-build.yml | 18 +- README.md | 22 +- RELEASING.md | 6 +- SPEC.md | 27 +- scripts/create-local-release.ps1 | 334 +++--- scripts/release-version.ps1 | 38 + scripts/test-release-version.ps1 | 66 ++ src/ClipDiff.Core/DiffEngine.cs | 683 ++++++++----- src/ClipDiff.Windows/App.xaml.cs | 100 +- src/ClipDiff.Windows/AppController.cs | 956 ++++++++---------- .../ApplicationCommandLine.cs | 58 ++ .../Explorer/ExplorerCommandClient.cs | 58 +- .../Explorer/ExplorerCommandProtocol.cs | 126 +-- .../Explorer/ExplorerCommandServer.cs | 273 ++--- .../Explorer/ExplorerComparisonRequests.cs | 111 ++ .../Explorer/ExplorerContextCommandLine.cs | 120 +-- .../ExplorerContextMenuRegistration.cs | 597 +++++------ .../ExplorerRegistrationCoordinator.cs | 143 +++ .../Explorer/ExplorerRegistryClaimStore.cs | 55 + .../ExternalDiff/ExternalDiffLauncher.cs | 372 +++---- .../ExternalDiff/ExternalDiffWorkspace.cs | 276 ++--- .../Ownership/SharedFileLease.cs | 46 + .../Startup/StartupRegistration.cs | 9 +- .../ViewModels/ComparisonPresentation.cs | 35 + .../DiffEngineBudgetTests.cs | 113 +++ .../ApplicationCommandLineTests.cs | 52 + .../ComparisonRequestTests.cs | 171 ++++ .../ExplorerCommandServerTests.cs | 93 ++ .../ExplorerRegistrationCoordinatorTests.cs | 258 +++++ .../ExternalDiffWorkspaceTests.cs | 229 +++-- .../StartupRegistrationTests.cs | 30 + 32 files changed, 3543 insertions(+), 1981 deletions(-) delete mode 100644 .github/workflows/release.yml create mode 100644 scripts/release-version.ps1 create mode 100644 scripts/test-release-version.ps1 create mode 100644 src/ClipDiff.Windows/ApplicationCommandLine.cs create mode 100644 src/ClipDiff.Windows/Explorer/ExplorerComparisonRequests.cs create mode 100644 src/ClipDiff.Windows/Explorer/ExplorerRegistrationCoordinator.cs create mode 100644 src/ClipDiff.Windows/Explorer/ExplorerRegistryClaimStore.cs create mode 100644 src/ClipDiff.Windows/Ownership/SharedFileLease.cs create mode 100644 src/ClipDiff.Windows/ViewModels/ComparisonPresentation.cs create mode 100644 tests/ClipDiff.Core.Tests/DiffEngineBudgetTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/ComparisonRequestTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/ExplorerCommandServerTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/ExplorerRegistrationCoordinatorTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 96bcc50..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -env: - DOTNET_CLI_TELEMETRY_OPTOUT: "true" - DOTNET_NOLOGO: "true" - -jobs: - release: - runs-on: windows-latest - timeout-minutes: 20 - - steps: - - name: Check out repository - uses: actions/checkout@v5 - - - name: Set up .NET 10 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - - - name: Validate release tag - shell: pwsh - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - $tag = $env:RELEASE_TAG - "RELEASE_VERSION=$($Matches.version)" >> $env:GITHUB_ENV - - - name: Build, test, and package - shell: pwsh - run: ./scripts/create-local-release.ps1 -Version $env:RELEASE_VERSION - - - name: Create GitHub release - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - run: | - $selfContained = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-self-contained.zip" - $net10 = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-net10.zip" - gh release create $env:RELEASE_TAG $selfContained $net10 --verify-tag --generate-notes --title "ClipDiff $env:RELEASE_TAG" diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 7bb21b9..a2c47c3 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -36,14 +36,13 @@ jobs: env: RELEASE_TAG: ${{ github.ref_name }} run: | - $tag = $env:RELEASE_TAG + . ./scripts/release-version.ps1 + $version = Get-ReleaseVersion -Version $env:RELEASE_TAG -FromTag + "RELEASE_VERSION=$version" >> $env:GITHUB_ENV - if ($tag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$') - { - throw "Release tags must look like v1.2.3 or v1.2.3-preview.1; got '$tag'." - } - - "RELEASE_VERSION=$($Matches.version)" >> $env:GITHUB_ENV + - name: Test release version propagation + shell: pwsh + run: ./scripts/test-release-version.ps1 - name: Build, test, and package shell: pwsh @@ -72,7 +71,8 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} run: | - $selfContained = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-self-contained.zip" - $net10 = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-net10.zip" + . ./scripts/release-version.ps1 + $selfContained = "releases/$(Get-ReleasePackageName -Version $env:RELEASE_VERSION -SelfContained $true).zip" + $net10 = "releases/$(Get-ReleasePackageName -Version $env:RELEASE_VERSION -SelfContained $false).zip" gh release create $env:RELEASE_TAG $selfContained $net10 --verify-tag --generate-notes --title "ClipDiff $env:RELEASE_TAG" diff --git a/README.md b/README.md index bf2cf72..aed65ae 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,18 @@ While clipboard monitoring is active, selecting exactly two files in Explorer al After ClipDiff has captured at least one value, and while clipboard monitoring is active, it adds **Compare with current ClipDiff capture** to the Explorer context menu for individual files. If the current capture came from a file, the command appends that filename so the comparison source is visible before it is invoked. Choosing it reads the selected file with the same conversion rules, promotes that result to **Current**, moves the former **Current** entry to **Previous**, and immediately uses the normal **Show Diff** workflow. It does not change the Windows clipboard. On Windows 11, this classic context-menu command may appear under **Show more options**. -Both context-menu registrations are per-user, require no administrator access, and use classic Explorer verbs, so Windows 11 may place them under **Show more options**. The two-file command appears only when exactly two files are selected and ClipDiff is running and monitoring; the individual-file command additionally requires a usable current capture. Losing the last capture removes the individual-file command; quitting removes both. A later ClipDiff start cleans up registrations left by an abnormal exit. +Both context-menu registrations are per-user, require no administrator access, and use classic Explorer verbs, so Windows 11 may place them under **Show more options**. The two-file command appears only when exactly two files are selected and ClipDiff is running and monitoring; the individual-file command additionally requires a usable current capture. Losing the last capture removes the individual-file command; quitting removes both when no other live session needs them. Concurrent sessions for the same user coordinate the shared registrations. Their individual-file label is generic because the registry is shared, while invocation validates the capture in the local session. A later ClipDiff start cleans up registrations left by an abnormal exit. Coordination retries brief lock contention; if a peer stays stalled beyond the retry deadline, the surviving instance reconciles registration on its next capture update or tray-menu opening. The two-file command uses a small native `ClipDiff.ShellExtension.dll` beside the executable. Its [Shell initialization interface](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ishellextinit-initialize) receives the complete selection, so it can show the command only for two files. It checks Shell metadata without reading file contents, then forwards the selection data object to ClipDiff's existing out-of-process COM drop target when clicked. Selected paths never appear in a process command line or the registry. The DLL holds the selection only for the menu's lifetime; file conversion and comparison run in ClipDiff. A memory-only readiness event keeps cached menus inactive after ClipDiff has exited. +A newer Explorer request or accepted clipboard value cancels pending selected-file work. The single-file command checks that its original capture is still current when the read finishes. Slow reads and incomplete pipe messages do not block receipt of newer commands; failed forwarding gives concise feedback. + Copies containing more than two files are ignored; ClipDiff never creates a comparison value from a file list. File contents and paths are never written by these built-in workflows; the resulting value or pair follows the same two-entry, in-memory history policy as copied text. For the individual-file command, Explorer necessarily supplies the directly selected file path to a short-lived ClipDiff process. ClipDiff forwards it to the existing tray process over a same-user, per-session local pipe. For the two-file command, Explorer marshals the selection directly to the running ClipDiff process. In both cases, each path is retained only with its in-memory entry for collision disambiguation; it is never logged or persisted. +For large comparisons, Myers line matching uses at most 8 MiB of frontier/trace integer payload and 2,000,000 search steps. When that budget is reached, ClipDiff preserves exact common leading/trailing lines and pairs the remaining block by position. All text and line numbers remain available, though matching lines inside that block may appear as changed. Document storage still grows with the number of input lines. + ## External diff viewers ClipDiff has command-line profiles for these developer tools: @@ -73,9 +77,9 @@ ClipDiff captures only future, non-empty text values (including the copied-file The built-in viewer keeps captured text in memory only. An external program cannot compare in-memory strings directly, so selecting an external viewer creates an explicit exception: after a one-time warning is accepted, ClipDiff writes the two values as UTF-8 plaintext files in a unique directory below `%LOCALAPPDATA%\ClipDiff\Temp`. Ordinary text uses `Previous clipboard.txt` and `Current clipboard.txt`; file-backed values use their source basename within separate `Previous` and `Current` child directories so even positional-only viewers expose the filename. The files are marked read-only. ClipDiff attempts to remove that directory after the launched process exits, when ClipDiff exits, and during its next startup. -This cleanup is best effort. A crash, power loss, open file handle, or external viewer that hands work to another process can leave files behind, and the selected program may cache or retain its own copy outside ClipDiff's control. Do not select an external viewer when this disk exposure is unacceptable. Cancelling the warning opens the built-in viewer without writing the files. +This cleanup is best effort. A crash, power loss, open file handle, or external viewer that hands work to another process can leave files behind, and the selected program may cache or retain its own copy outside ClipDiff's control. Do not select an external viewer when this disk exposure is unacceptable. Cancelling the warning opens the built-in viewer without writing the files. If automatic privacy clearing or a newer comparison invalidates the saved pair while the warning is open, neither OK nor Cancel reopens or exports it. -ClipDiff stores only the selected executable path, the one-time-warning acknowledgement, the chosen shortcut's modifier/key codes, and whether the login prompt has been shown in `%LOCALAPPDATA%\ClipDiff\settings.json`; clipboard text and previews are never stored there. Rebuilding or replacing the executable does not reset the acknowledgement. To retest the notice, close ClipDiff and set `PlaintextWarningAcknowledged` to `false` in that file; the selected program can remain unchanged. With the built-in viewer, normal exit loses all captured content. With an external viewer, normal exit also attempts to remove every temporary comparison directory. +ClipDiff stores only the selected executable path, the one-time-warning acknowledgement, the chosen shortcut's modifier/key codes, and whether the login prompt has been shown in `%LOCALAPPDATA%\ClipDiff\settings.json`; clipboard text and previews are never stored there. Rebuilding or replacing the executable does not reset the acknowledgement. To retest the notice, close ClipDiff and set `PlaintextWarningAcknowledged` to `false` in that file; the selected program can remain unchanged. With the built-in viewer, normal exit loses all captured content. With an external viewer, normal exit also attempts to remove this instance's temporary comparison directories. Empty exclusive owner leases protect live workspaces in another session from startup cleanup; leases contain no captured text. Shared Explorer registration claims contain only executable/DLL commands and menu metadata, and stale claims are removed at startup. Before reading text, ClipDiff inspects and honours these advisory [clipboard formats](https://learn.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats): @@ -177,13 +181,14 @@ It requires the .NET 10 Windows Desktop Runtime on the computer where it runs. It is not the distributable package. For the self-contained, validated two-file package, use the local release script below instead. -The pure `ClipDiff.Core` project and both policy test assemblies target ordinary `net10.0`, so they can run on macOS: +The pure `ClipDiff.Core` project and its tests target ordinary `net10.0`, so they can run on macOS: ```bash dotnet test tests/ClipDiff.Core.Tests/ClipDiff.Core.Tests.csproj -dotnet test tests/ClipDiff.Windows.Tests/ClipDiff.Windows.Tests.csproj ``` +`ClipDiff.Windows.Tests` targets Windows and references the WPF application; run the complete Windows suite on Windows. + The Windows project has `EnableWindowsTargeting=true`, which permits cross-compilation where Microsoft targeting packs are available. A successful macOS compile is not a functional Windows test. Clipboard formats, notification-area behaviour, global hotkeys, WPF presentation, and native cleanup still require Windows verification. Build the native menu extension on Windows with Visual Studio C++ build tools (the **Desktop development with C++** workload and a Windows SDK): @@ -200,7 +205,9 @@ An older test launcher could print `elevated=0, elevationType=2` after dropping If the native test run fails, include the full output from `Native build:` through the final `[FAIL]` line and script error. The output identifies the test case, expected visibility, direct-handler and Windows-aggregate results, HRESULT/Win32 errors, selection counts and Shell attributes, and effective COM/menu registration. A visibility mismatch also prints menu IDs, states, separator labels, and a read-only snapshot of Shell restriction settings and ClipDiff's Approved/Blocked entries. These probes do not change policy or read clipboard contents or selected file paths. Passing the direct-handler check but failing the Windows-aggregate check narrows the problem to Shell discovery/aggregation; it does not by itself establish that a work-machine policy is responsible. Microsoft's [Shell extension approval policy documentation](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-windowsexplorer#enforceshellextensionsecurity) explains one possible restriction. GitHub Actions runs the same release script on `windows-latest` for every push -and pull request. Each successful run provides `ClipDiff-win-x64-self-contained` +and pull request. It checks regular and prerelease tag versions, assembly metadata, +and asset names on every run. A `v1.2.3` or `v1.2.3-preview.1` tag publishes both +validated ZIPs using that same version. Each successful run provides `ClipDiff-win-x64-self-contained` and `ClipDiff-win-x64-net10` artifacts. Each contains only the executable and native DLL; extract both into the same directory. The workflow can be run manually from the repository's **Actions** tab as well. Actual Explorer desktop @@ -278,6 +285,9 @@ On a Windows desktop, verify: - neither the tray nor the viewer exposes a clear command, and the tray has no monitoring toggle; - the built-in viewer is selected by default, detected external programs appear under **Diff viewer**, and **Choose program...** accepts another executable; - selecting an external viewer shows the privacy warning once; cancelling uses the built-in viewer without creating plaintext files; +- clearing a recent clipboard value while that warning is open prevents both OK and Cancel from exporting or reopening the saved pair; +- a slow Explorer request cannot overwrite a newer completed pair, and copying a newer capture during a single-file request cancels that request; +- in two concurrent sessions for the same user, starting and quitting B preserves A's active external comparison files and Explorer commands; - each supported installed viewer receives the previous/current sides in the right order and with the documented labels and read-only switches where supported; - external comparison files appear only below `%LOCALAPPDATA%\ClipDiff\Temp`, contain the exact text, and are removed after the launched process exits, on ClipDiff exit, or on the next start; - removing or renaming the selected viewer executable causes **Show Diff** to fall back to the built-in viewer; diff --git a/RELEASING.md b/RELEASING.md index 86d8fb1..440e4fc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -64,7 +64,11 @@ a published release. git push origin v1.2.3 ``` -The **Windows build and release** workflow validates the tag, runs the complete release script, +The **Windows build and release** workflow is the sole tag-release workflow. +It shares tag validation and package naming with the release script, checks regular +and prerelease version propagation on ordinary builds, and verifies published +executable metadata. Tags such as `v1.2.3-preview.1` preserve the prerelease suffix +in the product version and both ZIP names. The workflow validates the tag, runs the complete release script, and creates a GitHub release with generated notes and both `ClipDiff-1.2.3-win-x64-self-contained.zip` and `ClipDiff-1.2.3-win-x64-net10.zip` attached. It will not publish if any build, diff --git a/SPEC.md b/SPEC.md index 0374e90..56e0855 100644 --- a/SPEC.md +++ b/SPEC.md @@ -205,6 +205,8 @@ While monitoring is active and at least one captured entry exists, register a pe While monitoring is active, also register a per-user Explorer context-menu command named **Compare two selected files with ClipDiff**. It must accept exactly two directly selected files without requiring an existing capture, convert both independently using the same single-file rules, atomically replace the history with the resulting pair, and immediately invoke the ordinary **Show Diff** workflow. The first path in Explorer's supplied selection order is previous and the second is current. Neither direct entry is eligible for the recent clipboard-clear heuristic. If monitoring is paused, or the invocation does not contain exactly two usable file-system paths, do not read a selected file or alter history. +Explorer comparison requests must retain their identity from file-read start through publication. A newer Explorer request supersedes a pending request even if its reader ignores cancellation. A clipboard history change or privacy clear cancels pending selected-file work. A single-file request also checks the original current entry's identity before insertion; it must never silently compare against a later capture. + The individual-file command may pass its selected path on its process command line and through same-user local IPC to the existing ClipDiff instance. The two-file command must instead receive the complete selection through a Shell data object and pass it to the existing out-of-process COM drop target, so neither selected path appears on a command line. Do not pass copied file paths or contents, decoded text, or a diff on a command line. Do not persist or log any selected file path. The individual-file command does not support multiple selections. The two-file command must be hidden unless exactly two file-system files are selected and monitoring is active. Use a native `IShellExtInit`/`IContextMenu` handler, whose initialization data object represents the complete selection. Do not use `IExplorerCommandState` for this count check: its documented array contains only one item. Check only selection count and Shell attributes, without requesting display names or reading file contents. The Shell selection data object may be referenced transiently for the menu's lifetime and must be released on invocation, reinitialization, or handler destruction. Hide the command if selection inspection fails. Invalid invocations must still be rejected without file I/O. ### 6.5 Empty clipboard changes @@ -445,9 +447,11 @@ If a second instance starts: ### 9.4 Explorer context menu -Use per-user registration below `HKCU\Software\Classes`; do not require administrator access or an installer. Register the individual-file static verb only while monitoring is active and a current entry exists. Register the two-file native context-menu handler under `*\shellex\ContextMenuHandlers` while monitoring is active and the native DLL and local COM drop target are available. Register the DLL's separate CLSID with `InprocServer32` and `ThreadingModel=Apartment`. This narrowly scoped native menu handler is the approved exception to avoiding in-process Explorer extensions; never load the CLR or perform file conversion inside Explorer. Remove the individual verb when captured text is cleared, and remove both menu registrations when monitoring is paused or ClipDiff exits. Remove owned stale registrations, including the obsolete two-file static verb and its `CommandStateHandler`, at the next start. The registry values may contain only executable/DLL paths, labels, icons, selection policy, COM identifiers, threading model, ownership markers, and server commands—never captured text, previews, selected paths, or diffs. +Use per-user registration below `HKCU\Software\Classes`; do not require administrator access or an installer. Register the individual-file static verb only while monitoring is active and a current entry exists. Register the two-file native context-menu handler under `*\shellex\ContextMenuHandlers` while monitoring is active and the native DLL and local COM drop target are available. Register the DLL's separate CLSID with `InprocServer32` and `ThreadingModel=Apartment`. This narrowly scoped native menu handler is the approved exception to avoiding in-process Explorer extensions; never load the CLR or perform file conversion inside Explorer. Remove the individual verb when captured text is cleared, and remove both menu registrations when monitoring is paused or ClipDiff exits, unless another live instance in the same user profile still requires them. Per-process claims below HKCU and empty exclusive file leases coordinate these shared registrations; startup removes only stale claims and shutdown reapplies surviving claims. With multiple live sessions, the shared individual-file label omits the capture filename; invocation still validates the local session's capture. Remove owned stale registrations, including the obsolete two-file static verb and its `CommandStateHandler`, at the next start. The registry values may contain only executable/DLL paths, labels, icons, selection policy, COM identifiers, threading model, ownership markers, and server commands—never captured text, previews, selected paths, or diffs. + +On Windows 11 these classic commands may appear below **Show more options**. Forward individual-file invocations to the existing per-session instance through a local pipe restricted to the current user; deliver two-file invocations through the registered COM drop target. A per-session, default-user-access named event, `Local\ClipDiff.ExplorerPairReady`, signals that the application is alive, monitoring, and has completed registration. Reset it before pausing or cleanup and close it on exit. The DLL must check it before menu display and invocation, including for cached handlers; it must not launch or contact the application merely to open a menu. The individual-file pipe accepts up to four incomplete messages concurrently with a one-second receipt deadline, independently of file processing. A later complete request supersedes an earlier incomplete message. Clients require a delivery acknowledgement within two seconds and show a concise failure if forwarding fails. -On Windows 11 these classic commands may appear below **Show more options**. Forward individual-file invocations to the existing per-session instance through a local pipe restricted to the current user; deliver two-file invocations through the registered COM drop target. A per-session, default-user-access named event, `Local\ClipDiff.ExplorerPairReady`, signals that the application is alive, monitoring, and has completed registration. Reset it before pausing or cleanup and close it on exit. The DLL must check it before menu display and invocation, including for cached handlers; it must not launch or contact the application merely to open a menu. A failure to register a handler or deliver a command is nonfatal and must not impair clipboard monitoring or tray operation. A missing DLL must leave no broken two-file menu entry. +A failure to register a handler or deliver a command is nonfatal and must not impair clipboard monitoring or tray operation. A missing DLL must leave no broken two-file menu entry. ## 10. Status and presentation models @@ -615,6 +619,8 @@ Preferred algorithm: Avoid a naive unbounded `O(n × m)` full matrix if it would behave badly for large clipboard values. +Limit Myers frontier/backtracking integer payload to 8 MiB and search work to 2,000,000 diagonal advances and line comparisons. On exhaustion, preserve exact common leading/trailing lines and pair the remaining edit block by position. Preserve all source text and line numbers. This deterministic fallback may show internal matching lines as changed and is not guaranteed to produce a shortest edit script. Output storage remains proportional to the input line count. + ### 13.1 Row generation Start with the edit script between old lines and new lines. @@ -897,11 +903,13 @@ When two entries are ready, **Show Diff** must launch the selected external prog Known command profiles must be provided for SourceGear DiffMerge, WinMerge, Meld, KDiff3, Beyond Compare, Araxis Merge, Visual Studio Code, Visual Studio, TortoiseGitMerge, TortoiseMerge, P4Merge, and ExamDiff Pro. Use each program's supported read-only, separate-instance, wait, and side-title options where available. A manually chosen unknown executable receives the previous and current file paths as two positional arguments. Construct arguments as separate process arguments rather than shell-concatenated text so spaces and special characters in paths remain safe. Clipboard contents must never appear in the command line. -Before the first external comparison for a user profile, show one modal privacy warning. It must state that the clipboard may contain passwords, tokens, or other secrets; external comparison requires temporary plaintext files; a crash or power loss can leave them behind; and the selected program may retain its own copies. The user may cancel. Cancelling must create no files and must open the built-in viewer. Remember only successful acknowledgement, so the warning is normally shown once. +Before the first external comparison for a user profile, show one modal privacy warning. It must state that the clipboard may contain passwords, tokens, or other secrets; external comparison requires temporary plaintext files; a crash or power loss can leave them behind; and the selected program may retain its own copies. The user may cancel. Cancelling must create no files and must open the built-in viewer. Remember only successful acknowledgement, so the warning is normally shown once. If clipboard history changes, privacy clearing occurs, the application exits, or a newer comparison is requested during the warning's nested message loop, abandon the saved comparison after confirmation. Neither acceptance nor cancellation may export or reopen invalidated text. After acknowledgement, create a unique per-comparison directory below `%LOCALAPPDATA%\ClipDiff\Temp`. Write ordinary clipboard values exactly as UTF-8 text to `Previous clipboard.txt` and `Current clipboard.txt`. For a file-backed value, use its source filename within a `Previous` or `Current` child directory so external viewers that expose only basenames still show the filename. For viewers that support side titles, use the same collision-disambiguated file labels as the built-in viewer. Do not reproduce original parent directories in the temporary workspace. Mark both files read-only and temporary. The files are the only permitted disk persistence of captured clipboard values. Do not reuse a directory between comparisons. -Track the process returned by the launch. Attempt to delete the comparison directory after that process exits, allowing a short grace period for handoff; when ClipDiff exits; and on ClipDiff's next startup to remove stale directories. Cleanup is best effort because crashes, power loss, open file handles, and programs that delegate to another process cannot be controlled. Normalize read-only attributes before deletion. Never log a temporary path together with clipboard text. +Track the process returned by the launch. Attempt to delete the comparison directory after that process exits, allowing a short grace period for handoff; when ClipDiff exits; and on ClipDiff's next startup to remove stale directories. Each live comparison holds an empty exclusive owner lease beside its directory. Startup cleanup skips directories whose leases are held by another live instance or session. Delete only the current instance's workspaces on exit; release their leases after deletion. No captured text is stored in leases. + +Cleanup is best effort because crashes, power loss, open file handles, and programs that delegate to another process cannot be controlled. Normalize read-only attributes before deletion. Never log a temporary path together with clipboard text. Persist application preferences in `%LOCALAPPDATA%\ClipDiff\settings.json`. The file may contain only the selected executable path, the one-time-warning acknowledgement, the global-shortcut modifier/virtual-key values, and whether the startup prompt has been shown. It must never contain clipboard text, previews, diffs, temporary-file contents, or selected-file paths. Returning to the built-in viewer clears the executable selection but need not reset the acknowledgement. @@ -1268,6 +1276,9 @@ On Windows: 1. Test a privacy-marked sample clipboard item and confirm its text is never requested/captured. 1. Test an unmarked value followed by an immediate clipboard clear and confirm the latest captured item is removed. 1. Test under Remote Desktop if Windows Server 2022 is the intended machine. +1. Hold an external-viewer warning open, automatically clear a recent capture, then try both OK and Cancel; neither may export or reopen the cleared comparison. +1. Start a slow Explorer comparison, then a faster newer comparison; only the newer pair may publish. Copy a different capture while a single-file read is pending and confirm the request does not use that newer capture. +1. Run ClipDiff in two concurrent sessions for the same Windows user: keep A's external comparison open, start and quit B, and confirm A's files and Explorer menus remain usable. 1. Confirm the built-in viewer is the default and detected supported programs appear in the Diff viewer submenu without being auto-selected. 1. Choose an external program, invoke Show Diff, and confirm the privacy warning appears before any temporary file is written. 1. Cancel the warning and confirm the built-in viewer opens and no external-diff temporary directory is created. @@ -1316,9 +1327,9 @@ Suggested publish properties: Do not enable trimming for the initial WPF build. -The native projects are built separately with `scripts/build-shell-extension.ps1 -Test`, so macOS can still compile and test the .NET solution. The release script runs this native build first. Distribute the executable and DLL together. Explorer can keep a loaded DLL locked after ClipDiff quits; updating into a new directory avoids overwriting a loaded DLL, and restarting Explorer or signing out may be needed to unload an older handler. Do not restart Explorer automatically. +The native projects are built separately with `scripts/build-shell-extension.ps1 -Test`, so macOS can still cross-compile the .NET application and run the pure core tests. The release script runs this native build first. Distribute the executable and DLL together. Explorer can keep a loaded DLL locked after ClipDiff quits; updating into a new directory avoids overwriting a loaded DLL, and restarting Explorer or signing out may be needed to unload an older handler. Do not restart Explorer automatically. -Continuous integration must call the same release script rather than duplicate the publish commands. A semantic-version tag in the form `v1.2.3` should run the complete build and test process, then create a GitHub release containing both validated versioned ZIPs. +Continuous integration must call the same release script rather than duplicate the publish commands. A semantic-version tag in the form `v1.2.3` or `v1.2.3-preview.1` should run the complete build and test process, then create a GitHub release containing both validated versioned ZIPs. One workflow owns tag releases. Share version validation and package naming with the packaging script; test regular/prerelease version propagation in ordinary CI and validate published executable metadata before exposing packages. For local releases, `create-local-release.ps1 -SkipNativeTests` may explicitly skip the native Explorer integration tests. It must still build and package the DLL and run the .NET tests. Native tests remain enabled by default and in CI. @@ -1382,8 +1393,10 @@ Existing settings without this field default to false. Do not prompt secondary instances or Explorer command invocations. An existing ClipDiff Run registration counts as an opt-in and suppresses the question. -Provide a checked **Start at login** tray-menu item. Store only the quoted +Provide a checked **Start at login** tray-menu item. Store the quoted executable path under the current user's Run key, using the value name ClipDiff. +For development launches hosted by dotnet, include the quoted host and quoted +entry-assembly path using the same application command builder as Explorer. Remove only that value when switched off. No administrator rights are needed. Refresh registration state when opening the menu. Registry failures must not crash the app; disable the toggle if reading fails and explain failed changes. diff --git a/scripts/create-local-release.ps1 b/scripts/create-local-release.ps1 index f38a809..ab5190f 100644 --- a/scripts/create-local-release.ps1 +++ b/scripts/create-local-release.ps1 @@ -1,11 +1,12 @@ [CmdletBinding()] param( - [switch]$Launch, - [switch]$SkipNativeTests, - [string]$Version + [switch]$Launch, + [switch]$SkipNativeTests, + [string]$Version ) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'release-version.ps1') $repositoryRoot = Split-Path -Parent $PSScriptRoot $releaseRoot = Join-Path $repositoryRoot 'releases' $publishRoot = Join-Path $repositoryRoot 'artifacts/release-publish' @@ -16,142 +17,211 @@ $windowsTests = Join-Path $repositoryRoot 'tests/ClipDiff.Windows.Tests/ClipDiff $nativeDll = Join-Path $repositoryRoot 'artifacts/native/Release/ClipDiff.ShellExtension.dll' $requiredPayload = @('ClipDiff.exe', 'ClipDiff.ShellExtension.dll') -function Assert-ReleasePayload([string]$Directory, [string]$Description) { - $actualPayload = @(Get-ChildItem $Directory -File | Select-Object -ExpandProperty Name | Sort-Object) - $unexpectedPayload = @(Compare-Object ($requiredPayload | Sort-Object) $actualPayload) - if ($unexpectedPayload.Count -ne 0) { - throw "$Description validation failed. Expected only: $($requiredPayload -join ', ')." - } +function Assert-ReleasePayload([string]$Directory, [string]$Description) +{ + $actualPayload = @(Get-ChildItem $Directory -File | Select-Object -ExpandProperty Name | Sort-Object) + $unexpectedPayload = @(Compare-Object ($requiredPayload | Sort-Object) $actualPayload) + + if ($unexpectedPayload.Count -ne 0) + { + throw "$Description validation failed. Expected only: $($requiredPayload -join ', ')." + } } -if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { - throw 'The .NET 10 SDK is required. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and open a new PowerShell window.' +if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) +{ + throw 'The .NET 10 SDK is required. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and open a new PowerShell window.' } $sdkVersion = & dotnet --version -if ($LASTEXITCODE -ne 0 -or -not $sdkVersion.StartsWith('10.')) { - throw "The .NET 10 SDK is required, but the active SDK is '$sdkVersion'. Run 'dotnet --list-sdks' to see installed SDKs." + +if ($LASTEXITCODE -ne 0 -or -not $sdkVersion.StartsWith('10.')) +{ + throw "The .NET 10 SDK is required, but the active SDK is '$sdkVersion'. Run 'dotnet --list-sdks' to see installed SDKs." } Push-Location $repositoryRoot -try { - if ([string]::IsNullOrWhiteSpace($Version)) { - $versionOutput = & dotnet msbuild $applicationProject -nologo -getProperty:Version - if ($LASTEXITCODE -ne 0) { throw 'Could not read the ClipDiff version from MSBuild.' } - $Version = ($versionOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim() - } - - if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { - throw "Release version '$Version' is invalid. Use a version such as 1.0.0 or 1.0.0-preview.1." - } - - $packages = @( - [pscustomobject]@{ - Name = "ClipDiff-$Version-win-x64-self-contained" - SelfContained = $true - Requirement = 'No separately installed .NET runtime required' - PublishDirectory = $null - StagingDirectory = $null - StagingArchive = $null - ReleaseDirectory = $null - ReleaseArchive = $null - }, - [pscustomobject]@{ - Name = "ClipDiff-$Version-win-x64-net10" - SelfContained = $false - Requirement = 'Requires the x64 .NET 10 Desktop Runtime' - PublishDirectory = $null - StagingDirectory = $null - StagingArchive = $null - ReleaseDirectory = $null - ReleaseArchive = $null - } - ) - - if ($SkipNativeTests) { - Write-Host 'Skipping native Explorer integration tests; the extension DLL will still be built.' - } - & (Join-Path $PSScriptRoot 'build-shell-extension.ps1') -Configuration Release -Test:(-not $SkipNativeTests) - - dotnet test $coreTests --configuration Release - if ($LASTEXITCODE -ne 0) { throw 'Core tests failed.' } - - dotnet test $windowsTests --configuration Release - if ($LASTEXITCODE -ne 0) { throw 'Windows policy tests failed.' } - - # dotnet publish does not clean custom output directories. Build both variants - # privately and validate both before replacing any public release output. - foreach ($package in $packages) { - $package.PublishDirectory = Join-Path $publishRoot $package.Name - $package.StagingDirectory = Join-Path $packageStagingRoot $package.Name - $package.StagingArchive = Join-Path $packageStagingRoot "$($package.Name).zip" - $package.ReleaseDirectory = Join-Path $releaseRoot $package.Name - $package.ReleaseArchive = Join-Path $releaseRoot "$($package.Name).zip" - - foreach ($path in @($package.PublishDirectory, $package.StagingDirectory)) { - if (Test-Path $path) { Remove-Item $path -Recurse -Force } - New-Item $path -ItemType Directory -Force | Out-Null - } - if (Test-Path $package.StagingArchive) { Remove-Item $package.StagingArchive -Force } - - $selfContained = $package.SelfContained.ToString().ToLowerInvariant() - dotnet publish $applicationProject ` - --configuration Release ` - --runtime win-x64 ` - --self-contained $selfContained ` - --output $package.PublishDirectory ` - -p:Version=$Version ` - -p:PublishSingleFile=true ` - -p:PublishTrimmed=false ` - -p:IncludeNativeLibrariesForSelfExtract=true - if ($LASTEXITCODE -ne 0) { throw "$($package.Name) publish failed." } - - foreach ($file in $requiredPayload) { - $source = if ($file -eq 'ClipDiff.ShellExtension.dll') { $nativeDll } else { Join-Path $package.PublishDirectory $file } - if (-not (Test-Path $source -PathType Leaf)) { throw "$($package.Name) is missing required file: $file" } - Copy-Item $source $package.StagingDirectory - } - - Assert-ReleasePayload $package.StagingDirectory "$($package.Name) directory" - Compress-Archive -Path (Join-Path $package.StagingDirectory '*') -DestinationPath $package.StagingArchive -CompressionLevel Optimal - - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($package.StagingArchive) - try { - $archivePayload = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) } | Select-Object -ExpandProperty FullName | Sort-Object) - $unexpectedArchivePayload = @(Compare-Object ($requiredPayload | Sort-Object) $archivePayload) - if ($unexpectedArchivePayload.Count -ne 0) { - throw "$($package.Name) archive validation failed. Expected only: $($requiredPayload -join ', ')." - } - } - finally { - $archive.Dispose() - } - } - - # Replace public outputs only after both variants pass validation, so a failed - # build cannot publish mismatched or partial packages. - New-Item $releaseRoot -ItemType Directory -Force | Out-Null - foreach ($package in $packages) { - if (Test-Path $package.ReleaseDirectory) { Remove-Item $package.ReleaseDirectory -Recurse -Force } - if (Test-Path $package.ReleaseArchive) { Remove-Item $package.ReleaseArchive -Force } - Move-Item $package.StagingDirectory $package.ReleaseDirectory - Move-Item $package.StagingArchive $package.ReleaseArchive - Remove-Item $package.PublishDirectory -Recurse -Force - - Write-Host '' - Write-Host "Release directory: $($package.ReleaseDirectory)" - Write-Host "Release archive: $($package.ReleaseArchive)" - Write-Host "Runtime: $($package.Requirement)" - } - - Write-Host "Payload: $($requiredPayload -join ', ')" - Write-Host 'Install: Extract both files from one package into the same directory, then run ClipDiff.exe.' - - if ($Launch) { - Start-Process (Join-Path $packages[0].ReleaseDirectory 'ClipDiff.exe') - } + +try +{ + if ([string]::IsNullOrWhiteSpace($Version)) + { + $versionOutput = & dotnet msbuild $applicationProject -nologo -getProperty:Version + + if ($LASTEXITCODE -ne 0) + { + throw 'Could not read the ClipDiff version from MSBuild.' + } + + $Version = ($versionOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim() + } + + $Version = Get-ReleaseVersion -Version $Version + + $packages = @( + [pscustomobject]@{ + Name = Get-ReleasePackageName -Version $Version -SelfContained $true + SelfContained = $true + Requirement = 'No separately installed .NET runtime required' + PublishDirectory = $null + StagingDirectory = $null + StagingArchive = $null + ReleaseDirectory = $null + ReleaseArchive = $null + }, + [pscustomobject]@{ + Name = Get-ReleasePackageName -Version $Version -SelfContained $false + SelfContained = $false + Requirement = 'Requires the x64 .NET 10 Desktop Runtime' + PublishDirectory = $null + StagingDirectory = $null + StagingArchive = $null + ReleaseDirectory = $null + ReleaseArchive = $null + } + ) + + if ($SkipNativeTests) + { + Write-Host 'Skipping native Explorer integration tests; the extension DLL will still be built.' + } + + & (Join-Path $PSScriptRoot 'build-shell-extension.ps1') -Configuration Release -Test:(-not $SkipNativeTests) + + dotnet test $coreTests --configuration Release + + if ($LASTEXITCODE -ne 0) + { + throw 'Core tests failed.' + } + + dotnet test $windowsTests --configuration Release + + if ($LASTEXITCODE -ne 0) + { + throw 'Windows policy tests failed.' + } + + # dotnet publish does not clean custom output directories. Build both variants + # privately and validate both before replacing any public release output. + + foreach ($package in $packages) + { + $package.PublishDirectory = Join-Path $publishRoot $package.Name + $package.StagingDirectory = Join-Path $packageStagingRoot $package.Name + $package.StagingArchive = Join-Path $packageStagingRoot "$($package.Name).zip" + $package.ReleaseDirectory = Join-Path $releaseRoot $package.Name + $package.ReleaseArchive = Join-Path $releaseRoot "$($package.Name).zip" + + foreach ($path in @($package.PublishDirectory, $package.StagingDirectory)) + { + if (Test-Path $path) + { + Remove-Item $path -Recurse -Force + } + + New-Item $path -ItemType Directory -Force | Out-Null + } + + if (Test-Path $package.StagingArchive) + { + Remove-Item $package.StagingArchive -Force + } + + $selfContained = $package.SelfContained.ToString().ToLowerInvariant() + dotnet publish $applicationProject ` + --configuration Release ` + --runtime win-x64 ` + --self-contained $selfContained ` + --output $package.PublishDirectory ` + -p:Version=$Version ` + -p:PublishSingleFile=true ` + -p:PublishTrimmed=false ` + -p:IncludeNativeLibrariesForSelfExtract=true + + if ($LASTEXITCODE -ne 0) + { + throw "$($package.Name) publish failed." + } + + $applicationVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $package.PublishDirectory 'ClipDiff.exe')) + $binaryVersion = $Version.Split('-')[0] + '.0' + + if ($applicationVersion.FileVersion -ne $binaryVersion -or + -not ($applicationVersion.ProductVersion -eq $Version -or $applicationVersion.ProductVersion.StartsWith("$Version+", [StringComparison]::Ordinal))) + { + throw "$($package.Name) executable version does not match the release version." + } + + foreach ($file in $requiredPayload) + { + $source = if ($file -eq 'ClipDiff.ShellExtension.dll') { $nativeDll } else { Join-Path $package.PublishDirectory $file } + + if (-not (Test-Path $source -PathType Leaf)) + { + throw "$($package.Name) is missing required file: $file" + } + + Copy-Item $source $package.StagingDirectory + } + + Assert-ReleasePayload $package.StagingDirectory "$($package.Name) directory" + Compress-Archive -Path (Join-Path $package.StagingDirectory '*') -DestinationPath $package.StagingArchive -CompressionLevel Optimal + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.StagingArchive) + + try + { + $archivePayload = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) } | Select-Object -ExpandProperty FullName | Sort-Object) + $unexpectedArchivePayload = @(Compare-Object ($requiredPayload | Sort-Object) $archivePayload) + + if ($unexpectedArchivePayload.Count -ne 0) + { + throw "$($package.Name) archive validation failed. Expected only: $($requiredPayload -join ', ')." + } + } + finally + { + $archive.Dispose() + } + } + + # Replace public outputs only after both variants pass validation, so a failed + # build cannot publish mismatched or partial packages. + New-Item $releaseRoot -ItemType Directory -Force | Out-Null + + foreach ($package in $packages) + { + if (Test-Path $package.ReleaseDirectory) + { + Remove-Item $package.ReleaseDirectory -Recurse -Force + } + + if (Test-Path $package.ReleaseArchive) + { + Remove-Item $package.ReleaseArchive -Force + } + + Move-Item $package.StagingDirectory $package.ReleaseDirectory + Move-Item $package.StagingArchive $package.ReleaseArchive + Remove-Item $package.PublishDirectory -Recurse -Force + + Write-Host '' + Write-Host "Release directory: $($package.ReleaseDirectory)" + Write-Host "Release archive: $($package.ReleaseArchive)" + Write-Host "Runtime: $($package.Requirement)" + } + + Write-Host "Payload: $($requiredPayload -join ', ')" + Write-Host 'Install: Extract both files from one package into the same directory, then run ClipDiff.exe.' + + if ($Launch) + { + Start-Process (Join-Path $packages[0].ReleaseDirectory 'ClipDiff.exe') + } } -finally { - Pop-Location +finally +{ + Pop-Location } diff --git a/scripts/release-version.ps1 b/scripts/release-version.ps1 new file mode 100644 index 0000000..b37f6f4 --- /dev/null +++ b/scripts/release-version.ps1 @@ -0,0 +1,38 @@ +function Get-ReleaseVersion +{ + param( + [string]$Version, + [switch]$FromTag + ) + + $pattern = '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$' + + if ($FromTag) + { + if (-not $Version.StartsWith('v', [StringComparison]::Ordinal)) + { + throw "Release tags must start with v; got '$Version'." + } + + $Version = $Version.Substring(1) + } + + if ($Version -notmatch $pattern) + { + throw "Release version '$Version' is invalid. Use a version such as 1.2.3 or 1.2.3-preview.1." + } + + return $Version +} + +function Get-ReleasePackageName +{ + param( + [string]$Version, + [bool]$SelfContained + ) + + $resolvedVersion = Get-ReleaseVersion -Version $Version + $variant = if ($SelfContained) { 'self-contained' } else { 'net10' } + return "ClipDiff-$resolvedVersion-win-x64-$variant" +} diff --git a/scripts/test-release-version.ps1 b/scripts/test-release-version.ps1 new file mode 100644 index 0000000..3717d84 --- /dev/null +++ b/scripts/test-release-version.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'release-version.ps1') +$applicationProject = Join-Path $PSScriptRoot '../src/ClipDiff.Windows/ClipDiff.Windows.csproj' + +foreach ($version in @('1.2.3', '1.2.3-preview.1')) +{ + $tagVersion = Get-ReleaseVersion -Version "v$version" -FromTag + + if ($tagVersion -ne $version -or (Get-ReleaseVersion -Version $version) -ne $version) + { + throw "The release tag did not resolve to '$version'." + } + + foreach ($selfContained in @($true, $false)) + { + $variant = if ($selfContained) { 'self-contained' } else { 'net10' } + $packageName = Get-ReleasePackageName -Version $tagVersion -SelfContained $selfContained + + if ("$packageName.zip" -ne "ClipDiff-$version-win-x64-$variant.zip") + { + throw 'The release asset name does not contain the resolved tag version.' + } + } + + $propertyOutput = & dotnet msbuild $applicationProject -nologo -target:GetAssemblyVersion -getProperty:Version,AssemblyVersion,FileVersion,InformationalVersion "-p:Version=$tagVersion" + + if ($LASTEXITCODE -ne 0) + { + throw 'Could not resolve release assembly version properties.' + } + + $properties = ($propertyOutput -join "`n" | ConvertFrom-Json).Properties + $binaryVersion = $version.Split('-')[0] + '.0' + + if ($properties.Version -ne $version -or + $properties.AssemblyVersion -ne $binaryVersion -or + $properties.FileVersion -ne $binaryVersion -or + -not ($properties.InformationalVersion -eq $version -or $properties.InformationalVersion.StartsWith("$version+", [StringComparison]::Ordinal))) + { + throw 'Release assembly metadata does not match the resolved tag version.' + } +} + +foreach ($tag in @('', '1.2.3', 'v', 'v1.2', 'v1.2.3+metadata', 'v1.2.3/invalid')) +{ + $rejected = $false + + try + { + Get-ReleaseVersion -Version $tag -FromTag | Out-Null + } + catch + { + $rejected = $true + } + + if (-not $rejected) + { + throw "Invalid release tag '$tag' was accepted." + } +} + +Write-Host 'Release tag, assembly metadata, and package-name checks passed.' diff --git a/src/ClipDiff.Core/DiffEngine.cs b/src/ClipDiff.Core/DiffEngine.cs index 749c31f..7348fb7 100644 --- a/src/ClipDiff.Core/DiffEngine.cs +++ b/src/ClipDiff.Core/DiffEngine.cs @@ -2,276 +2,415 @@ namespace ClipDiff; public sealed class DiffEngine { - private readonly Func idFactory; - - public DiffEngine(Func? idFactory = null) - { - this.idFactory = idFactory ?? Guid.NewGuid; - } - - public DiffDocument Compare( - ClipboardEntry previous, - ClipboardEntry current, - DateTimeOffset? createdAt = null, - bool ignoreSpacing = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(previous); - ArgumentNullException.ThrowIfNull(current); - - cancellationToken.ThrowIfCancellationRequested(); - var oldLines = TextLines.Split(previous.Text); - var newLines = TextLines.Split(current.Text); - var oldKeys = ignoreSpacing ? oldLines.Select(InlineDiff.NormalizeSpacing).ToArray() : oldLines; - var newKeys = ignoreSpacing ? newLines.Select(InlineDiff.NormalizeSpacing).ToArray() : newLines; - var edits = ShortestEditScript(oldKeys, newKeys, cancellationToken); - // Restore original text after matching normalized keys; display and copy never normalize it. - edits = edits.Select(edit => edit with - { - OldText = edit.OldIndex < 0 ? null : oldLines[edit.OldIndex], - NewText = edit.NewIndex < 0 ? null : newLines[edit.NewIndex] - }).ToArray(); - var inline = new InlineDiff(ignoreSpacing, cancellationToken); - var rows = GenerateRows(edits, cancellationToken).Select(inline.Highlight).ToArray(); - cancellationToken.ThrowIfCancellationRequested(); - var summary = new DiffSummary( - rows.Count(row => row.Kind == DiffKind.Inserted), - rows.Count(row => row.Kind == DiffKind.Removed), - rows.Count(row => row.Kind == DiffKind.Changed), - rows.Count(row => row.Kind == DiffKind.Equal)); - var labels = DiffFormatting.Labels(previous, current); - - return new DiffDocument( - idFactory(), - previous with { SourceFilePath = null }, - current with { SourceFilePath = null }, - rows, - summary, - createdAt ?? DateTimeOffset.Now, - labels); - } - - private IReadOnlyList GenerateRows(IReadOnlyList edits, CancellationToken cancellationToken) - { - var rows = new List(); - var index = 0; - - while (index < edits.Count) - { - cancellationToken.ThrowIfCancellationRequested(); - if (edits[index].Kind == EditKind.Equal) - { - var equal = edits[index++]; - rows.Add(new DiffRow( - idFactory(), - equal.OldIndex + 1, - equal.NewIndex + 1, - equal.OldText, - equal.NewText, - DiffKind.Equal)); - continue; - } - - var removed = new List(); - var inserted = new List(); - while (index < edits.Count && edits[index].Kind != EditKind.Equal) - { - var edit = edits[index++]; - if (edit.Kind == EditKind.Removed) - { - removed.Add(edit); - } - else - { - inserted.Add(edit); - } - } - - var pairedCount = Math.Min(removed.Count, inserted.Count); - for (var pair = 0; pair < pairedCount; pair++) - { - rows.Add(new DiffRow( - idFactory(), - removed[pair].OldIndex + 1, - inserted[pair].NewIndex + 1, - removed[pair].OldText, - inserted[pair].NewText, - DiffKind.Changed)); - } - - for (var oldIndex = pairedCount; oldIndex < removed.Count; oldIndex++) - { - var edit = removed[oldIndex]; - rows.Add(new DiffRow( - idFactory(), - edit.OldIndex + 1, - null, - edit.OldText, - null, - DiffKind.Removed)); - } - - for (var newIndex = pairedCount; newIndex < inserted.Count; newIndex++) - { - var edit = inserted[newIndex]; - rows.Add(new DiffRow( - idFactory(), - null, - edit.NewIndex + 1, - null, - edit.NewText, - DiffKind.Inserted)); - } - } - - return rows; - } - - private static IReadOnlyList ShortestEditScript(string[] oldLines, string[] newLines, CancellationToken cancellationToken) - { - var oldCount = oldLines.Length; - var newCount = newLines.Length; - var max = checked(oldCount + newCount); - var offset = max + 1; - var frontier = new int[checked((max * 2) + 3)]; - var trace = new List(Math.Min(max + 1, 4096)); - frontier[offset + 1] = 0; - - for (var distance = 0; distance <= max; distance++) - { - cancellationToken.ThrowIfCancellationRequested(); - for (var diagonal = -distance; diagonal <= distance; diagonal += 2) - { - cancellationToken.ThrowIfCancellationRequested(); - var frontierIndex = offset + diagonal; - int oldIndex; - if (diagonal == -distance || - (diagonal != distance && frontier[frontierIndex - 1] < frontier[frontierIndex + 1])) - { - oldIndex = frontier[frontierIndex + 1]; - } - else - { - oldIndex = frontier[frontierIndex - 1] + 1; - } - - var newIndex = oldIndex - diagonal; - while (oldIndex < oldCount && newIndex < newCount && - string.Equals(oldLines[oldIndex], newLines[newIndex], StringComparison.Ordinal)) - { - oldIndex++; - newIndex++; - } - - frontier[frontierIndex] = oldIndex; - if (oldIndex >= oldCount && newIndex >= newCount) - { - trace.Add((int[])frontier.Clone()); - return Backtrack(trace, distance, offset, oldLines, newLines, cancellationToken); - } - } - - trace.Add((int[])frontier.Clone()); - } - - throw new InvalidOperationException("Unable to compute a line difference."); - } - - private static IReadOnlyList Backtrack( - IReadOnlyList trace, - int distance, - int offset, - string[] oldLines, - string[] newLines, - CancellationToken cancellationToken) - { - var edits = new List(oldLines.Length + newLines.Length); - var oldIndex = oldLines.Length; - var newIndex = newLines.Length; - - for (var depth = distance; depth > 0; depth--) - { - cancellationToken.ThrowIfCancellationRequested(); - var previousFrontier = trace[depth - 1]; - var diagonal = oldIndex - newIndex; - int previousDiagonal; - if (diagonal == -depth || - (diagonal != depth && - previousFrontier[offset + diagonal - 1] < previousFrontier[offset + diagonal + 1])) - { - previousDiagonal = diagonal + 1; - } - else - { - previousDiagonal = diagonal - 1; - } - - var previousOldIndex = previousFrontier[offset + previousDiagonal]; - var previousNewIndex = previousOldIndex - previousDiagonal; - - while (oldIndex > previousOldIndex && newIndex > previousNewIndex) - { - oldIndex--; - newIndex--; - edits.Add(Edit.Equal(oldIndex, newIndex, oldLines[oldIndex])); - } - - if (oldIndex == previousOldIndex) - { - newIndex--; - edits.Add(Edit.Inserted(newIndex, newLines[newIndex])); - } - else - { - oldIndex--; - edits.Add(Edit.Removed(oldIndex, oldLines[oldIndex])); - } - } - - while (oldIndex > 0 && newIndex > 0) - { - oldIndex--; - newIndex--; - edits.Add(Edit.Equal(oldIndex, newIndex, oldLines[oldIndex])); - } - - while (oldIndex > 0) - { - oldIndex--; - edits.Add(Edit.Removed(oldIndex, oldLines[oldIndex])); - } - - while (newIndex > 0) - { - newIndex--; - edits.Add(Edit.Inserted(newIndex, newLines[newIndex])); - } - - edits.Reverse(); - return edits; - } - - private enum EditKind - { - Equal, - Inserted, - Removed - } - - private sealed record Edit( - EditKind Kind, - int OldIndex, - int NewIndex, - string? OldText, - string? NewText) - { - public static Edit Equal(int oldIndex, int newIndex, string text) => - new(EditKind.Equal, oldIndex, newIndex, text, text); - - public static Edit Inserted(int newIndex, string text) => - new(EditKind.Inserted, -1, newIndex, null, text); - - public static Edit Removed(int oldIndex, string text) => - new(EditKind.Removed, oldIndex, -1, text, null); - } + private const long MaximumTraceBytes = 8 * 1024 * 1024; + private const int MaximumSearchWork = 2_000_000; + private readonly Func idFactory; + + public DiffEngine(Func? generateId = null) + { + idFactory = generateId ?? Guid.NewGuid; + } + + public DiffDocument Compare( + ClipboardEntry previous, + ClipboardEntry current, + DateTimeOffset? createdAt = null, + bool ignoreSpacing = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(current); + + cancellationToken.ThrowIfCancellationRequested(); + + var oldLines = TextLines.Split(previous.Text); + var newLines = TextLines.Split(current.Text); + var oldKeys = ignoreSpacing ? oldLines.Select(InlineDiff.NormalizeSpacing).ToArray() : oldLines; + var newKeys = ignoreSpacing ? newLines.Select(InlineDiff.NormalizeSpacing).ToArray() : newLines; + + var edits = ShortestEditScript(oldKeys, newKeys, cancellationToken); + + // Restore original text after matching normalized keys; display and copy never normalize it. + edits = edits.Select(edit => edit with + { + OldText = edit.OldIndex < 0 ? null : oldLines[edit.OldIndex], + NewText = edit.NewIndex < 0 ? null : newLines[edit.NewIndex] + }).ToArray(); + var inline = new InlineDiff(ignoreSpacing, cancellationToken); + var rows = GenerateRows(edits, cancellationToken).Select(inline.Highlight).ToArray(); + + cancellationToken.ThrowIfCancellationRequested(); + + var summary = new DiffSummary( + rows.Count(row => row.Kind == DiffKind.Inserted), + rows.Count(row => row.Kind == DiffKind.Removed), + rows.Count(row => row.Kind == DiffKind.Changed), + rows.Count(row => row.Kind == DiffKind.Equal)); + var labels = DiffFormatting.Labels(previous, current); + + return new DiffDocument( + idFactory(), + previous with { SourceFilePath = null }, + current with { SourceFilePath = null }, + rows, + summary, + createdAt ?? DateTimeOffset.Now, + labels); + } + + private IReadOnlyList GenerateRows(IReadOnlyList edits, CancellationToken cancellationToken) + { + var rows = new List(); + var index = 0; + + while (index < edits.Count) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (edits[index].Kind == EditKind.Equal) + { + var equal = edits[index++]; + rows.Add(new DiffRow( + idFactory(), + equal.OldIndex + 1, + equal.NewIndex + 1, + equal.OldText, + equal.NewText, + DiffKind.Equal)); + continue; + } + + var removed = new List(); + var inserted = new List(); + + while (index < edits.Count && edits[index].Kind != EditKind.Equal) + { + cancellationToken.ThrowIfCancellationRequested(); + + var edit = edits[index++]; + + if (edit.Kind == EditKind.Removed) + { + removed.Add(edit); + } + else + { + inserted.Add(edit); + } + } + + var pairedCount = Math.Min(removed.Count, inserted.Count); + + for (var pair = 0; pair < pairedCount; pair++) + { + cancellationToken.ThrowIfCancellationRequested(); + + rows.Add(new DiffRow( + idFactory(), + removed[pair].OldIndex + 1, + inserted[pair].NewIndex + 1, + removed[pair].OldText, + inserted[pair].NewText, + DiffKind.Changed)); + } + + for (var oldIndex = pairedCount; oldIndex < removed.Count; oldIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var edit = removed[oldIndex]; + rows.Add(new DiffRow( + idFactory(), + edit.OldIndex + 1, + null, + edit.OldText, + null, + DiffKind.Removed)); + } + + for (var newIndex = pairedCount; newIndex < inserted.Count; newIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var edit = inserted[newIndex]; + rows.Add(new DiffRow( + idFactory(), + null, + edit.NewIndex + 1, + null, + edit.NewText, + DiffKind.Inserted)); + } + } + + return rows; + } + + private static IReadOnlyList ShortestEditScript(string[] oldLines, string[] newLines, CancellationToken cancellationToken) + { + var oldCount = oldLines.Length; + var newCount = newLines.Length; + var max = checked(oldCount + newCount); + var frontierLength = ((long)max * 2) + 3; + var frontierBytes = frontierLength * sizeof(int); + + if (frontierBytes > MaximumTraceBytes) + { + return CoarseEditScript(oldLines, newLines, cancellationToken); + } + + var offset = max + 1; + var frontier = new int[(int)frontierLength]; + var trace = new List(); + var traceBytes = frontierBytes; + var remainingWork = MaximumSearchWork; + frontier[offset + 1] = 0; + + for (var distance = 0; distance <= max; distance++) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Account for the working frontier and every retained backtracking snapshot. + if (traceBytes + frontierBytes > MaximumTraceBytes) + { + return CoarseEditScript(oldLines, newLines, cancellationToken); + } + + for (var diagonal = -distance; diagonal <= distance; diagonal += 2) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (remainingWork-- == 0) + { + return CoarseEditScript(oldLines, newLines, cancellationToken); + } + + var frontierIndex = offset + diagonal; + int oldIndex; + + if (diagonal == -distance || + (diagonal != distance && frontier[frontierIndex - 1] < frontier[frontierIndex + 1])) + { + oldIndex = frontier[frontierIndex + 1]; + } + else + { + oldIndex = frontier[frontierIndex - 1] + 1; + } + + var newIndex = oldIndex - diagonal; + + while (oldIndex < oldCount && newIndex < newCount) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (remainingWork-- == 0) + { + return CoarseEditScript(oldLines, newLines, cancellationToken); + } + + if (!string.Equals(oldLines[oldIndex], newLines[newIndex], StringComparison.Ordinal)) + { + break; + } + + oldIndex++; + newIndex++; + } + + frontier[frontierIndex] = oldIndex; + + if (oldIndex >= oldCount && newIndex >= newCount) + { + trace.Add((int[])frontier.Clone()); + return Backtrack(trace, distance, offset, oldLines, newLines, cancellationToken); + } + } + + trace.Add((int[])frontier.Clone()); + traceBytes += frontierBytes; + } + + throw new InvalidOperationException("Unable to compute a line difference."); + } + + private static IReadOnlyList CoarseEditScript( + string[] oldLines, + string[] newLines, + CancellationToken cancellationToken) + { + // A bounded search may miss internal matches, but never omits or changes source lines. + var prefix = 0; + + while (prefix < oldLines.Length && prefix < newLines.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.Equals(oldLines[prefix], newLines[prefix], StringComparison.Ordinal)) + { + break; + } + + prefix++; + } + + var oldEnd = oldLines.Length; + var newEnd = newLines.Length; + + while (oldEnd > prefix && newEnd > prefix) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.Equals(oldLines[oldEnd - 1], newLines[newEnd - 1], StringComparison.Ordinal)) + { + break; + } + + oldEnd--; + newEnd--; + } + + var edits = new List(oldLines.Length + newLines.Length); + + for (var index = 0; index < prefix; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + + edits.Add(Edit.Equal(index, index, oldLines[index])); + } + + for (var index = prefix; index < oldEnd; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + + edits.Add(Edit.Removed(index, oldLines[index])); + } + + for (var index = prefix; index < newEnd; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + + edits.Add(Edit.Inserted(index, newLines[index])); + } + + while (oldEnd < oldLines.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + + edits.Add(Edit.Equal(oldEnd, newEnd, oldLines[oldEnd])); + oldEnd++; + newEnd++; + } + + return edits; + } + + private static IReadOnlyList Backtrack( + IReadOnlyList trace, + int distance, + int offset, + string[] oldLines, + string[] newLines, + CancellationToken cancellationToken) + { + var edits = new List(oldLines.Length + newLines.Length); + var oldIndex = oldLines.Length; + var newIndex = newLines.Length; + + for (var depth = distance; depth > 0; depth--) + { + cancellationToken.ThrowIfCancellationRequested(); + + var previousFrontier = trace[depth - 1]; + var diagonal = oldIndex - newIndex; + int previousDiagonal; + + if (diagonal == -depth || + (diagonal != depth && + previousFrontier[offset + diagonal - 1] < previousFrontier[offset + diagonal + 1])) + { + previousDiagonal = diagonal + 1; + } + else + { + previousDiagonal = diagonal - 1; + } + + var previousOldIndex = previousFrontier[offset + previousDiagonal]; + var previousNewIndex = previousOldIndex - previousDiagonal; + + while (oldIndex > previousOldIndex && newIndex > previousNewIndex) + { + cancellationToken.ThrowIfCancellationRequested(); + + oldIndex--; + newIndex--; + edits.Add(Edit.Equal(oldIndex, newIndex, oldLines[oldIndex])); + } + + if (oldIndex == previousOldIndex) + { + newIndex--; + edits.Add(Edit.Inserted(newIndex, newLines[newIndex])); + } + else + { + oldIndex--; + edits.Add(Edit.Removed(oldIndex, oldLines[oldIndex])); + } + } + + while (oldIndex > 0 && newIndex > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + oldIndex--; + newIndex--; + edits.Add(Edit.Equal(oldIndex, newIndex, oldLines[oldIndex])); + } + + while (oldIndex > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + oldIndex--; + edits.Add(Edit.Removed(oldIndex, oldLines[oldIndex])); + } + + while (newIndex > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + newIndex--; + edits.Add(Edit.Inserted(newIndex, newLines[newIndex])); + } + + edits.Reverse(); + + return edits; + } + + private enum EditKind + { + Equal, + Inserted, + Removed + } + + private sealed record Edit( + EditKind Kind, + int OldIndex, + int NewIndex, + string? OldText, + string? NewText) + { + public static Edit Equal(int oldIndex, int newIndex, string text) => + new(EditKind.Equal, oldIndex, newIndex, text, text); + + public static Edit Inserted(int newIndex, string text) => + new(EditKind.Inserted, -1, newIndex, null, text); + + public static Edit Removed(int oldIndex, string text) => + new(EditKind.Removed, oldIndex, -1, text, null); + } } diff --git a/src/ClipDiff.Windows/App.xaml.cs b/src/ClipDiff.Windows/App.xaml.cs index 5dd7e20..cd5cf9e 100644 --- a/src/ClipDiff.Windows/App.xaml.cs +++ b/src/ClipDiff.Windows/App.xaml.cs @@ -6,56 +6,60 @@ namespace ClipDiff.Windows; public partial class App : System.Windows.Application { - private Mutex? instanceMutex; - private AppController? controller; - private bool ownsMutex; - - protected override void OnStartup(StartupEventArgs args) - { - base.OnStartup(args); - - var isExplorerCommand = ExplorerContextCommandLine.TryGetSelectedFile( - args.Args, - out var selectedFilePath); - - instanceMutex = new Mutex(true, @"Local\ClipDiff", out ownsMutex); - if (!ownsMutex) - { - if (isExplorerCommand) - { - ExplorerCommandClient.TrySendSelectedFile(selectedFilePath); - } - - instanceMutex.Dispose(); - instanceMutex = null; - Shutdown(); - return; - } - - controller = new AppController(); - if (isExplorerCommand) - { - controller.CompareWithCurrent(selectedFilePath); - } + private Mutex? instanceMutex; + private AppController? controller; + private bool ownsMutex; + + protected override void OnStartup(StartupEventArgs args) + { + base.OnStartup(args); + + var isExplorerCommand = ExplorerContextCommandLine.TryGetSelectedFile( + args.Args, + out var selectedFilePath); + + instanceMutex = new Mutex(true, @"Local\ClipDiff", out ownsMutex); + if (!ownsMutex) + { + if (isExplorerCommand) + { + if (!ExplorerCommandClient.TrySendSelectedFile(selectedFilePath)) + { + System.Windows.MessageBox.Show("ClipDiff could not receive the Explorer comparison. Try the command again.", + "ClipDiff", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + instanceMutex.Dispose(); + instanceMutex = null; + Shutdown(); + return; + } + + controller = new AppController(); + if (isExplorerCommand) + { + controller.CompareWithCurrent(selectedFilePath); + } else { Dispatcher.InvokeAsync(controller.InitializeStartAtLogin); } - } - - protected override void OnExit(ExitEventArgs args) - { - controller?.Dispose(); - controller = null; - - if (ownsMutex) - { - instanceMutex?.ReleaseMutex(); - ownsMutex = false; - } - - instanceMutex?.Dispose(); - instanceMutex = null; - base.OnExit(args); - } + } + + protected override void OnExit(ExitEventArgs args) + { + controller?.Dispose(); + controller = null; + + if (ownsMutex) + { + instanceMutex?.ReleaseMutex(); + ownsMutex = false; + } + + instanceMutex?.Dispose(); + instanceMutex = null; + base.OnExit(args); + } } diff --git a/src/ClipDiff.Windows/AppController.cs b/src/ClipDiff.Windows/AppController.cs index f793ae8..dc77982 100644 --- a/src/ClipDiff.Windows/AppController.cs +++ b/src/ClipDiff.Windows/AppController.cs @@ -16,64 +16,75 @@ namespace ClipDiff.Windows; internal sealed class AppController : IDisposable { - private readonly DiffEngine diffEngine = new(); - private readonly LatestComparison comparison = new(); - private (ClipboardEntry Previous, ClipboardEntry Current, DiffSideLabels Labels)? comparisonInput; - private readonly ClipDiffSettingsStore settingsStore; - private readonly ExternalDiffLauncher externalDiffLauncher; - private readonly NativeMessageWindow messageWindow; - private readonly ClipboardMonitor clipboardMonitor; - private readonly ClipboardWriter clipboardWriter; - private readonly CopiedFileTextReader copiedFileTextReader = new(); - private readonly ClipboardHistory history; - private readonly GlobalHotKey hotKey; - private readonly TrayIconController trayIcon; - private readonly DiffWindowViewModel viewModel; - private readonly ExplorerCommandServer explorerCommandServer; - private readonly ExplorerDropTargetServer explorerDropTargetServer; - private readonly ExplorerContextMenuRegistration explorerContextMenuRegistration; - private readonly CancellationTokenSource shutdown = new(); - private ClipDiffSettings settings; - private IReadOnlyList externalDiffTools; - private DiffWindow? diffWindow; - private AboutWindow? aboutWindow; - private ShortcutWindow? shortcutWindow; + private readonly DiffEngine diffEngine = new(); + private readonly LatestComparison comparison = new(); + private readonly ComparisonPresentation comparisonPresentation = new(); + private readonly ExplorerComparisonRequests explorerComparisonRequests; + private (ClipboardEntry Previous, ClipboardEntry Current, DiffSideLabels Labels)? comparisonInput; + private readonly ClipDiffSettingsStore settingsStore; + private readonly ExternalDiffLauncher externalDiffLauncher; + private readonly NativeMessageWindow messageWindow; + private readonly ClipboardMonitor clipboardMonitor; + private readonly ClipboardWriter clipboardWriter; + private readonly CopiedFileTextReader copiedFileTextReader = new(); + private readonly ClipboardHistory history; + private readonly GlobalHotKey hotKey; + private readonly TrayIconController trayIcon; + private readonly DiffWindowViewModel viewModel; + private readonly ExplorerCommandServer explorerCommandServer; + private readonly ExplorerDropTargetServer explorerDropTargetServer; + private readonly ExplorerContextMenuRegistration explorerContextMenuRegistration; + private ClipDiffSettings settings; + private IReadOnlyList externalDiffTools; + private DiffWindow? diffWindow; + private AboutWindow? aboutWindow; + private ShortcutWindow? shortcutWindow; private readonly StartupRegistration startupRegistration = new(); - private bool disposed; - - public AppController() - { - messageWindow = new NativeMessageWindow(); - clipboardMonitor = new ClipboardMonitor(messageWindow); - clipboardWriter = new ClipboardWriter(messageWindow.Handle); - history = new ClipboardHistory(clipboardMonitor.BaselineSequence); - settingsStore = new ClipDiffSettingsStore(); - settings = settingsStore.Load(); - hotKey = new GlobalHotKey(new NativeHotKeyBackend(messageWindow), HotKeyGesture.Normalize(settings.HotKey)); - externalDiffTools = ExternalDiffToolDiscovery.FindInstalled(settings.SelectedExecutablePath); - externalDiffLauncher = new ExternalDiffLauncher(); - trayIcon = new TrayIconController( - externalDiffTools, - GetSelectedExternalDiffTool()?.ExecutablePath); - viewModel = new DiffWindowViewModel(CopyDiff, Recompare); - explorerCommandServer = new ExplorerCommandServer(CompareWithSelectedFileAsync); - explorerDropTargetServer = new ExplorerDropTargetServer( - OnExplorerFilesSelected, - () => !disposed && history.IsMonitoring); - explorerContextMenuRegistration = new ExplorerContextMenuRegistration(); - - clipboardMonitor.ObservationReceived += OnClipboardObservation; - hotKey.Pressed += OnHotKeyPressed; - trayIcon.ShowDiffRequested += OnShowDiffRequested; - trayIcon.ShortcutRequested += OnShortcutRequested; - trayIcon.DiffToolSelected += OnDiffToolSelected; - trayIcon.ChooseDiffToolRequested += OnChooseDiffToolRequested; - trayIcon.AboutRequested += OnAboutRequested; - trayIcon.QuitRequested += OnQuitRequested; + private bool disposed; + + public AppController() + { + messageWindow = new NativeMessageWindow(); + clipboardMonitor = new ClipboardMonitor(messageWindow); + clipboardWriter = new ClipboardWriter(messageWindow.Handle); + history = new ClipboardHistory(clipboardMonitor.BaselineSequence); + settingsStore = new ClipDiffSettingsStore(); + settings = settingsStore.Load(); + hotKey = new GlobalHotKey(new NativeHotKeyBackend(messageWindow), HotKeyGesture.Normalize(settings.HotKey)); + externalDiffTools = ExternalDiffToolDiscovery.FindInstalled(settings.SelectedExecutablePath); + externalDiffLauncher = new ExternalDiffLauncher(); + trayIcon = new TrayIconController( + externalDiffTools, + GetSelectedExternalDiffTool()?.ExecutablePath); + viewModel = new DiffWindowViewModel(CopyDiff, Recompare); + explorerComparisonRequests = new ExplorerComparisonRequests( + history, + ReadSelectedFilesAsync, + CancelPendingComparison, + () => + { + UpdatePresentation(); + ShowDiff(); + }, + SystemSounds.Beep.Play); + explorerCommandServer = new ExplorerCommandServer(CompareWithSelectedFileAsync); + explorerDropTargetServer = new ExplorerDropTargetServer( + OnExplorerFilesSelected, + () => !disposed && history.IsMonitoring); + explorerContextMenuRegistration = new ExplorerContextMenuRegistration(); + + clipboardMonitor.ObservationReceived += OnClipboardObservation; + hotKey.Pressed += OnHotKeyPressed; + trayIcon.ShowDiffRequested += OnShowDiffRequested; + trayIcon.ShortcutRequested += OnShortcutRequested; + trayIcon.DiffToolSelected += OnDiffToolSelected; + trayIcon.ChooseDiffToolRequested += OnChooseDiffToolRequested; + trayIcon.AboutRequested += OnAboutRequested; + trayIcon.QuitRequested += OnQuitRequested; trayIcon.ToggleStartAtLoginRequested += OnToggleStartAtLoginRequested; trayIcon.MenuOpening += OnTrayMenuOpening; - UpdatePresentation(); - } + UpdatePresentation(); + } public void InitializeStartAtLogin() { @@ -128,7 +139,11 @@ private void RememberStartupPrompt() } } - private void OnTrayMenuOpening(object? sender, EventArgs args) => RefreshStartAtLogin(); + private void OnTrayMenuOpening(object? sender, EventArgs args) + { + RefreshStartAtLogin(); + UpdatePresentation(); + } private void RefreshStartAtLogin() { @@ -154,472 +169,399 @@ private void OnToggleStartAtLoginRequested(object? sender, EventArgs args) private static void ShowStartupError(string message) => System.Windows.MessageBox.Show(message, "ClipDiff", MessageBoxButton.OK, MessageBoxImage.Warning); - public void CompareWithCurrent(string selectedFilePath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(selectedFilePath); - - if (!disposed) - { - _ = CompareWithSelectedFileAsync(selectedFilePath); - } - } - - public void Dispose() - { - if (disposed) - { - return; - } - - disposed = true; - ClearComparison(); - shutdown.Cancel(); - clipboardMonitor.ObservationReceived -= OnClipboardObservation; - hotKey.Pressed -= OnHotKeyPressed; - trayIcon.ShowDiffRequested -= OnShowDiffRequested; - trayIcon.ShortcutRequested -= OnShortcutRequested; - trayIcon.DiffToolSelected -= OnDiffToolSelected; - trayIcon.ChooseDiffToolRequested -= OnChooseDiffToolRequested; - trayIcon.AboutRequested -= OnAboutRequested; - trayIcon.QuitRequested -= OnQuitRequested; + public void CompareWithCurrent(string selectedFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(selectedFilePath); + + if (!disposed) + { + _ = CompareWithSelectedFileAsync(selectedFilePath); + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + ClearComparison(); + clipboardMonitor.ObservationReceived -= OnClipboardObservation; + hotKey.Pressed -= OnHotKeyPressed; + trayIcon.ShowDiffRequested -= OnShowDiffRequested; + trayIcon.ShortcutRequested -= OnShortcutRequested; + trayIcon.DiffToolSelected -= OnDiffToolSelected; + trayIcon.ChooseDiffToolRequested -= OnChooseDiffToolRequested; + trayIcon.AboutRequested -= OnAboutRequested; + trayIcon.QuitRequested -= OnQuitRequested; trayIcon.ToggleStartAtLoginRequested -= OnToggleStartAtLoginRequested; trayIcon.MenuOpening -= OnTrayMenuOpening; - explorerContextMenuRegistration.Dispose(); - explorerDropTargetServer.Dispose(); - explorerCommandServer.Dispose(); - trayIcon.Dispose(); - externalDiffLauncher.Dispose(); - hotKey.Dispose(); - clipboardMonitor.Dispose(); - if (shortcutWindow is not null) - { - shortcutWindow.Close(); - shortcutWindow = null; - } - - if (diffWindow is not null) - { - diffWindow.AllowClose = true; - diffWindow.Close(); - diffWindow = null; - } - - if (aboutWindow is not null) - { - aboutWindow.AllowClose = true; - aboutWindow.Close(); - aboutWindow = null; - } - - viewModel.ClearDocument(); - history.Clear(); - messageWindow.Dispose(); - shutdown.Dispose(); - } - - private void OnClipboardObservation(object? sender, ClipboardObservation observation) - { - var change = history.Apply(observation); - if (change == ClipboardHistoryChange.RemovedByRecentClear) - { - ClearComparison(); - viewModel.ClearDocument(); - } - - UpdatePresentation(); - } - - private void OnHotKeyPressed(object? sender, EventArgs args) - { - if (shortcutWindow?.TryCaptureRegisteredShortcut(hotKey.Gesture) == true) - { - return; - } - - ShowDiff(); - } - - private void OnShowDiffRequested(object? sender, EventArgs args) => ShowDiff(); - - private void OnAboutRequested(object? sender, EventArgs args) - { - aboutWindow ??= new AboutWindow(); - if (aboutWindow.WindowState == WindowState.Minimized) - { - aboutWindow.WindowState = WindowState.Normal; - } - - aboutWindow.Show(); - aboutWindow.Activate(); - } - - private void OnShortcutRequested(object? sender, EventArgs args) - { - if (shortcutWindow is not null) - { - shortcutWindow.Activate(); - return; - } - - var window = new ShortcutWindow(hotKey.Gesture, TryChangeHotKey); - shortcutWindow = window; - try - { - window.ShowDialog(); - } - finally - { - if (ReferenceEquals(shortcutWindow, window)) - { - shortcutWindow = null; - } - } - } - - private HotKeyChangeResult TryChangeHotKey(HotKeyGesture gesture) - { - if (disposed) - { - return HotKeyChangeResult.Unavailable; - } - - var updatedSettings = settings with { HotKey = gesture }; - var result = HotKeyChangeTransaction.TrySave( - hotKey, gesture, () => settingsStore.TrySave(updatedSettings)); - if (result == HotKeyChangeResult.Success) - { - settings = updatedSettings; - } - - UpdatePresentation(); - return result; - } - - private void OnDiffToolSelected(object? sender, ExternalDiffToolSelectedEventArgs args) - { - settings = settings with - { - SelectedExecutablePath = args.Choice?.ExecutablePath - }; - settingsStore.TrySave(settings); - trayIcon.SetDiffTools(externalDiffTools, args.Choice?.ExecutablePath); - } - - private void OnChooseDiffToolRequested(object? sender, EventArgs args) - { - var dialog = new Microsoft.Win32.OpenFileDialog - { - Title = "Choose a diff program", - Filter = "Windows applications (*.exe)|*.exe|All files (*.*)|*.*", - CheckFileExists = true, - Multiselect = false - }; - if (dialog.ShowDialog() != true) - { - return; - } - - var choice = new ExternalDiffToolChoice( - ExternalDiffToolCatalog.MatchExecutable(dialog.FileName), - Path.GetFullPath(dialog.FileName)); - externalDiffTools = - [ - .. externalDiffTools - .Where(existing => !string.Equals(existing.ExecutablePath, choice.ExecutablePath, - StringComparison.OrdinalIgnoreCase)), - - choice - ]; - OnDiffToolSelected(this, new ExternalDiffToolSelectedEventArgs(choice)); - } - - private void OnQuitRequested(object? sender, EventArgs args) - { - Dispose(); - System.Windows.Application.Current.Shutdown(); - } - - private void ShowDiff() - { - if (history.Previous is not { } previous || history.Current is not { } current) - { - SystemSounds.Beep.Play(); - return; - } - - comparison.Cancel(); - var selectedTool = GetSelectedExternalDiffTool(); - if (selectedTool is not null && ConfirmExternalDiffRisk() && - externalDiffLauncher.TryLaunch(selectedTool, previous, current)) - { - return; - } - - ShowBuiltInDiff(previous, current); - } - - private void ShowBuiltInDiff(ClipboardEntry previous, ClipboardEntry current) - { - comparisonInput = (previous with { SourceFilePath = null }, - current with { SourceFilePath = null }, DiffFormatting.Labels(previous, current)); - Recompare(); - } - - private void Recompare() - { - if (comparisonInput is not { } input || disposed) + explorerContextMenuRegistration.Dispose(); + explorerDropTargetServer.Dispose(); + explorerCommandServer.Dispose(); + trayIcon.Dispose(); + externalDiffLauncher.Dispose(); + hotKey.Dispose(); + clipboardMonitor.Dispose(); + + if (shortcutWindow is not null) + { + shortcutWindow.Close(); + shortcutWindow = null; + } + + if (diffWindow is not null) + { + diffWindow.AllowClose = true; + diffWindow.Close(); + diffWindow = null; + } + + if (aboutWindow is not null) + { + aboutWindow.AllowClose = true; + aboutWindow.Close(); + aboutWindow = null; + } + + viewModel.ClearDocument(); + history.Clear(); + messageWindow.Dispose(); + } + + private void OnClipboardObservation(object? sender, ClipboardObservation observation) + { + var change = history.Apply(observation); + + if (change == ClipboardHistoryChange.Accepted) + { + explorerComparisonRequests.Cancel(); + CancelPendingComparison(); + } + + if (change == ClipboardHistoryChange.RemovedByRecentClear) + { + ClearComparison(); + viewModel.ClearDocument(); + } + + UpdatePresentation(); + } + + private void OnHotKeyPressed(object? sender, EventArgs args) + { + if (shortcutWindow?.TryCaptureRegisteredShortcut(hotKey.Gesture) == true) + { + return; + } + + ShowDiff(); + } + + private void OnShowDiffRequested(object? sender, EventArgs args) => ShowDiff(); + + private void OnAboutRequested(object? sender, EventArgs args) + { + aboutWindow ??= new AboutWindow(); + + if (aboutWindow.WindowState == WindowState.Minimized) + { + aboutWindow.WindowState = WindowState.Normal; + } + + aboutWindow.Show(); + aboutWindow.Activate(); + } + + private void OnShortcutRequested(object? sender, EventArgs args) + { + if (shortcutWindow is not null) + { + shortcutWindow.Activate(); + return; + } + + var window = new ShortcutWindow(hotKey.Gesture, TryChangeHotKey); + shortcutWindow = window; + try + { + window.ShowDialog(); + } + finally + { + if (ReferenceEquals(shortcutWindow, window)) + { + shortcutWindow = null; + } + } + } + + private HotKeyChangeResult TryChangeHotKey(HotKeyGesture gesture) + { + if (disposed) + { + return HotKeyChangeResult.Unavailable; + } + + var updatedSettings = settings with { HotKey = gesture }; + var result = HotKeyChangeTransaction.TrySave( + hotKey, gesture, () => settingsStore.TrySave(updatedSettings)); + if (result == HotKeyChangeResult.Success) + { + settings = updatedSettings; + } + + UpdatePresentation(); + return result; + } + + private void OnDiffToolSelected(object? sender, ExternalDiffToolSelectedEventArgs args) + { + settings = settings with + { + SelectedExecutablePath = args.Choice?.ExecutablePath + }; + + if (!settingsStore.TrySave(settings)) + { + ShowStartupError("ClipDiff could not save the diff viewer selection. It may reset next time."); + } + + trayIcon.SetDiffTools(externalDiffTools, args.Choice?.ExecutablePath); + } + + private void OnChooseDiffToolRequested(object? sender, EventArgs args) + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Title = "Choose a diff program", + Filter = "Windows applications (*.exe)|*.exe|All files (*.*)|*.*", + CheckFileExists = true, + Multiselect = false + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + var choice = new ExternalDiffToolChoice( + ExternalDiffToolCatalog.MatchExecutable(dialog.FileName), + Path.GetFullPath(dialog.FileName)); + externalDiffTools = + [ + .. externalDiffTools + .Where(existing => !string.Equals(existing.ExecutablePath, choice.ExecutablePath, + StringComparison.OrdinalIgnoreCase)), + + choice + ]; + OnDiffToolSelected(this, new ExternalDiffToolSelectedEventArgs(choice)); + } + + private void OnQuitRequested(object? sender, EventArgs args) + { + Dispose(); + System.Windows.Application.Current.Shutdown(); + } + + private void ShowDiff() + { + if (disposed || history.Previous is not { } previous || history.Current is not { } current) + { + SystemSounds.Beep.Play(); + return; + } + + explorerComparisonRequests.Cancel(); + comparison.Cancel(); + var selectedTool = GetSelectedExternalDiffTool(); + comparisonPresentation.Show( + selectedTool is null ? null : ConfirmExternalDiffRisk, + () => externalDiffLauncher.TryLaunch(selectedTool!, previous, current), + () => ShowBuiltInDiff(previous, current)); + } + + private void ShowBuiltInDiff(ClipboardEntry previous, ClipboardEntry current) + { + comparisonInput = (previous with { SourceFilePath = null }, + current with { SourceFilePath = null }, DiffFormatting.Labels(previous, current)); + Recompare(); + } + + private void Recompare() + { + if (comparisonInput is not { } input || disposed) { return; } _ = CompareBuiltInAsync(input.Previous, input.Current, input.Labels); - } - - private async Task CompareBuiltInAsync(ClipboardEntry previous, ClipboardEntry current, DiffSideLabels labels) - { - var ignoreSpacing = viewModel.IgnoreSpacing; - try - { - await comparison.RunAsync( - token => Task.Run(() => diffEngine.Compare(previous, current, - ignoreSpacing: ignoreSpacing, cancellationToken: token) with { Labels = labels }, token), - document => - { - viewModel.Load(document); - diffWindow ??= new DiffWindow { DataContext = viewModel }; - if (diffWindow.WindowState == WindowState.Minimized) + } + + private async Task CompareBuiltInAsync(ClipboardEntry previous, ClipboardEntry current, DiffSideLabels labels) + { + var ignoreSpacing = viewModel.IgnoreSpacing; + + try + { + await comparison.RunAsync( + token => Task.Run(() => diffEngine.Compare(previous, current, + ignoreSpacing: ignoreSpacing, cancellationToken: token) with { Labels = labels }, token), + document => + { + viewModel.Load(document); + diffWindow ??= new DiffWindow { DataContext = viewModel }; + + if (diffWindow.WindowState == WindowState.Minimized) { diffWindow.WindowState = WindowState.Normal; } diffWindow.Show(); - diffWindow.Activate(); - }); - } - catch (Exception exception) when (exception is InvalidOperationException or OverflowException) - { - // Never include input text in diagnostics or user-facing errors. - if (!disposed) + diffWindow.Activate(); + }); + } + catch (Exception exception) when (exception is InvalidOperationException or OverflowException) + { + // Never include input text in diagnostics or user-facing errors. + if (!disposed) { SystemSounds.Beep.Play(); } } - } - - private void ClearComparison() - { - comparison.Cancel(); - comparisonInput = null; - } - - private async Task CompareWithSelectedFileAsync(string selectedFilePath) - { - try - { - var dispatcher = System.Windows.Application.Current.Dispatcher; - var canCompare = await dispatcher.InvokeAsync( - () => !disposed && history.IsMonitoring && history.Current is not null).Task.ConfigureAwait(false); - if (!canCompare) - { - await dispatcher.InvokeAsync(SystemSounds.Beep.Play).Task.ConfigureAwait(false); - return; - } - - string fullPath; - try - { - fullPath = Path.GetFullPath(selectedFilePath); - } - catch (Exception exception) when (exception is ArgumentException or NotSupportedException or - PathTooLongException or System.Security.SecurityException) - { - await dispatcher.InvokeAsync(SystemSounds.Beep.Play).Task.ConfigureAwait(false); - return; - } - - var selectedValue = await copiedFileTextReader.ReadFileAsync( - fullPath, - shutdown.Token).ConfigureAwait(false); - if (selectedValue is null) - { - await dispatcher.InvokeAsync(SystemSounds.Beep.Play).Task.ConfigureAwait(false); - return; - } - - await dispatcher.InvokeAsync(() => - { - if (disposed) - { - return; - } - - if (!history.IsMonitoring || history.Current is null) - { - SystemSounds.Beep.Play(); - return; - } - - history.AcceptDirectText( - selectedValue.Text, - DateTimeOffset.Now, - selectedValue.FileName, - selectedValue.FilePath); - UpdatePresentation(); - ShowDiff(); - }).Task.ConfigureAwait(false); - } - catch (OperationCanceledException) when (disposed) - { - } - } - - private void OnExplorerFilesSelected(IReadOnlyList selectedFilePaths) => - _ = CompareSelectedFilesAsync(selectedFilePaths); - - private async Task CompareSelectedFilesAsync(IReadOnlyList selectedFilePaths) - { - if (!ExplorerFileSelection.TryGetPair( - selectedFilePaths, - out var previousFilePath, - out var currentFilePath)) - { - SystemSounds.Beep.Play(); - return; - } - - try - { - var dispatcher = System.Windows.Application.Current.Dispatcher; - var canCompare = await dispatcher.InvokeAsync( - () => !disposed && history.IsMonitoring).Task.ConfigureAwait(false); - if (!canCompare) - { - await dispatcher.InvokeAsync(SystemSounds.Beep.Play).Task.ConfigureAwait(false); - return; - } - - var selectedValues = await copiedFileTextReader.ReadValuesAsync( - [previousFilePath, currentFilePath], - shutdown.Token).ConfigureAwait(false); - if (selectedValues.Count != 2) - { - await dispatcher.InvokeAsync(SystemSounds.Beep.Play).Task.ConfigureAwait(false); - return; - } - - await dispatcher.InvokeAsync(() => - { - if (disposed) - { - return; - } - - if (!history.IsMonitoring) - { - SystemSounds.Beep.Play(); - return; - } - - var previous = selectedValues[0]; - var current = selectedValues[1]; - history.AcceptDirectPair( - previous.Text, - current.Text, - DateTimeOffset.Now, - previous.FileName, - current.FileName, - previous.FilePath, - current.FilePath); - UpdatePresentation(); - ShowDiff(); - }).Task.ConfigureAwait(false); - } - catch (OperationCanceledException) when (disposed) - { - } - } - - private ExternalDiffToolChoice? GetSelectedExternalDiffTool() - { - if (string.IsNullOrWhiteSpace(settings.SelectedExecutablePath)) - { - return null; - } - - return externalDiffTools.FirstOrDefault(choice => string.Equals( - choice.ExecutablePath, - settings.SelectedExecutablePath, - StringComparison.OrdinalIgnoreCase)); - } - - private bool ConfirmExternalDiffRisk() - { - if (settings.PlaintextWarningAcknowledged) - { - return true; - } - - var result = NativeMethods.ShowMessageBox( - nint.Zero, - "External diff programs require ClipDiff to write the previous and current comparison text to " + - "read-only plaintext files under your local ClipDiff temporary folder. Comparison text may contain " + - "passwords, tokens, or other secrets.\n\n" + - "ClipDiff attempts to delete the files after the diff program closes, when ClipDiff exits, and on " + - "its next start. Files may remain after a crash or power loss, and the chosen program may retain " + - "its own copies. Continue with the external diff program?", - "ClipDiff external diff privacy notice", - NativeMethods.MbOkCancel | - NativeMethods.MbIconWarning | - NativeMethods.MbDefButton2 | - NativeMethods.MbTaskModal | - NativeMethods.MbSetForeground | - NativeMethods.MbTopmost); - if (result != NativeMethods.DialogResultOk) - { - return false; - } - - settings = settings with { PlaintextWarningAcknowledged = true }; - settingsStore.TrySave(settings); - return true; - } - - private void CopyDiff() - { - if (viewModel.Document is not { } document) - { - SystemSounds.Beep.Play(); - return; - } - - var text = DiffFormatting.Unified(document); - if (!clipboardWriter.TryWriteProtectedText(text, out var sequenceNumber)) - { - SystemSounds.Beep.Play(); - return; - } - - clipboardMonitor.SuppressOwnWrite(sequenceNumber); - history.Apply(ClipboardObservation.OwnWrite(sequenceNumber, DateTimeOffset.Now)); - UpdatePresentation(); - } - - private void UpdatePresentation() - { - var status = clipboardMonitor.IsRegistered || !history.IsMonitoring - ? history.Status - : "Clipboard listener unavailable"; - trayIcon.Update( - status, - hotKey.IsRegistered, - hotKey.Gesture.DisplayText, - history.Current, - history.Previous); - explorerContextMenuRegistration.SetState( - history.IsMonitoring, - history.Current is not null, - explorerDropTargetServer.IsRegistered, - history.Current?.SourceFileName); - } + } + + private void CancelPendingComparison() + { + comparisonPresentation.Cancel(); + comparison.Cancel(); + } + + private void ClearComparison() + { + explorerComparisonRequests.Cancel(); + CancelPendingComparison(); + comparisonInput = null; + } + + private async ValueTask> ReadSelectedFilesAsync( + IReadOnlyList paths, CancellationToken cancellationToken) + { + // Opening or inspecting a network file can block before ReadFileAsync's + // first await. Keep that work off the dispatcher and abandon stale waits. + return await Task.Run(() => copiedFileTextReader.ReadValuesAsync(paths, cancellationToken).AsTask(), + cancellationToken).WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private Task CompareWithSelectedFileAsync(string selectedFilePath) => + System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + disposed ? Task.CompletedTask : explorerComparisonRequests.RunAsync([selectedFilePath], true)).Task.Unwrap(); + + private void OnExplorerFilesSelected(IReadOnlyList selectedFilePaths) + { + if (!ExplorerFileSelection.TryGetPair(selectedFilePaths, out var previousPath, out var currentPath)) + { + SystemSounds.Beep.Play(); + return; + } + + if (!disposed) + { + _ = explorerComparisonRequests.RunAsync([previousPath, currentPath], false); + } + } + + private ExternalDiffToolChoice? GetSelectedExternalDiffTool() + { + if (string.IsNullOrWhiteSpace(settings.SelectedExecutablePath)) + { + return null; + } + + return externalDiffTools.FirstOrDefault(choice => string.Equals( + choice.ExecutablePath, + settings.SelectedExecutablePath, + StringComparison.OrdinalIgnoreCase)); + } + + private bool ConfirmExternalDiffRisk() + { + if (settings.PlaintextWarningAcknowledged) + { + return true; + } + + var result = NativeMethods.ShowMessageBox( + nint.Zero, + "External diff programs require ClipDiff to write the previous and current comparison text to " + + "read-only plaintext files under your local ClipDiff temporary folder. Comparison text may contain " + + "passwords, tokens, or other secrets.\n\n" + + "ClipDiff attempts to delete the files after the diff program closes, when ClipDiff exits, and on " + + "its next start. Files may remain after a crash or power loss, and the chosen program may retain " + + "its own copies. Continue with the external diff program?", + "ClipDiff external diff privacy notice", + NativeMethods.MbOkCancel | + NativeMethods.MbIconWarning | + NativeMethods.MbDefButton2 | + NativeMethods.MbTaskModal | + NativeMethods.MbSetForeground | + NativeMethods.MbTopmost); + + if (result != NativeMethods.DialogResultOk) + { + return false; + } + + settings = settings with { PlaintextWarningAcknowledged = true }; + + if (!settingsStore.TrySave(settings)) + { + ShowStartupError("ClipDiff could not save the privacy acknowledgement. The notice may appear next time."); + } + + return true; + } + + private void CopyDiff() + { + if (viewModel.Document is not { } document) + { + SystemSounds.Beep.Play(); + return; + } + + var text = DiffFormatting.Unified(document); + + if (!clipboardWriter.TryWriteProtectedText(text, out var sequenceNumber)) + { + SystemSounds.Beep.Play(); + return; + } + + clipboardMonitor.SuppressOwnWrite(sequenceNumber); + history.Apply(ClipboardObservation.OwnWrite(sequenceNumber, DateTimeOffset.Now)); + UpdatePresentation(); + } + + private void UpdatePresentation() + { + var status = clipboardMonitor.IsRegistered || !history.IsMonitoring + ? history.Status + : "Clipboard listener unavailable"; + trayIcon.Update( + status, + hotKey.IsRegistered, + hotKey.Gesture.DisplayText, + history.Current, + history.Previous); + explorerContextMenuRegistration.SetState( + history.IsMonitoring, + history.Current is not null, + explorerDropTargetServer.IsRegistered, + history.Current?.SourceFileName); + } } diff --git a/src/ClipDiff.Windows/ApplicationCommandLine.cs b/src/ClipDiff.Windows/ApplicationCommandLine.cs new file mode 100644 index 0000000..2de0928 --- /dev/null +++ b/src/ClipDiff.Windows/ApplicationCommandLine.cs @@ -0,0 +1,58 @@ +using System.IO; + +namespace ClipDiff.Windows; + +internal static class ApplicationCommandLine +{ + public static string Build(string processPath, string? entryAssemblyPath) + { + if (!TryBuild(processPath, entryAssemblyPath, out var command)) + { + throw new ArgumentException("The application requires an absolute executable path and, when hosted by dotnet, an absolute entry-assembly path."); + } + + return command; + } + + public static bool TryBuild(string? processPath, string? entryAssemblyPath, out string command) + { + command = string.Empty; + + if (!IsAbsolutePath(processPath)) + { + return false; + } + + var separatorIndex = Math.Max(processPath!.LastIndexOf('/'), processPath.LastIndexOf('\\')); + var fileName = processPath[(separatorIndex + 1)..]; + var isDotnetHost = string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) || + string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase); + + if (isDotnetHost && !IsAbsolutePath(entryAssemblyPath)) + { + return false; + } + + command = $"\"{processPath}\""; + + if (isDotnetHost) + { + command += $" \"{entryAssemblyPath}\""; + } + + return true; + } + + private static bool IsAbsolutePath(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Contains('"') || value.Contains('\0')) + { + return false; + } + + // Recognize Windows paths when the pure command tests run on another OS. + return Path.IsPathFullyQualified(value) || + (value.Length >= 3 && char.IsAsciiLetter(value[0]) && value[1] == ':' && value[2] is '\\' or '/') || + value.StartsWith(@"\\", StringComparison.Ordinal); + } +} diff --git a/src/ClipDiff.Windows/Explorer/ExplorerCommandClient.cs b/src/ClipDiff.Windows/Explorer/ExplorerCommandClient.cs index 29351f9..ded8ee3 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerCommandClient.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerCommandClient.cs @@ -6,37 +6,37 @@ namespace ClipDiff.Windows.Explorer; internal static class ExplorerCommandClient { - private const int ConnectionTimeoutMilliseconds = 2_000; - private static readonly string PipeName = CreatePipeName(); + private static readonly TimeSpan DeliveryTimeout = TimeSpan.FromSeconds(2); + private static readonly string PipeName = CreatePipeName(); - public static bool TrySendSelectedFile(string filePath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + public static bool TrySendSelectedFile(string filePath) => + TrySendSelectedFileAsync(filePath, PipeName).GetAwaiter().GetResult(); - try - { - using var pipe = new NamedPipeClientStream( - ".", - PipeName, - PipeDirection.Out, - PipeOptions.Asynchronous); - pipe.Connect(ConnectionTimeoutMilliseconds); - ExplorerCommandProtocol.WriteFilePathAsync(pipe, filePath).AsTask().GetAwaiter().GetResult(); - return true; - } - catch (Exception exception) when (exception is TimeoutException or IOException or - UnauthorizedAccessException or InvalidOperationException or - ArgumentException or ObjectDisposedException) - { - return false; - } - } + internal static async Task TrySendSelectedFileAsync(string filePath, string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - internal static string GetPipeName() => PipeName; + try + { + using var deadline = new CancellationTokenSource(DeliveryTimeout); + using var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + await pipe.ConnectAsync(deadline.Token).ConfigureAwait(false); + await ExplorerCommandProtocol.WriteFilePathAsync(pipe, filePath, deadline.Token).ConfigureAwait(false); + return await ExplorerCommandProtocol.ReadDeliveryResultAsync(pipe, deadline.Token).ConfigureAwait(false); + } + catch (Exception exception) when (exception is TimeoutException or IOException or + UnauthorizedAccessException or InvalidOperationException or ArgumentException or + ObjectDisposedException or OperationCanceledException) + { + return false; + } + } - private static string CreatePipeName() - { - using var process = Process.GetCurrentProcess(); - return $"ClipDiff.ExplorerCommand.{process.SessionId}"; - } + internal static string GetPipeName() => PipeName; + + private static string CreatePipeName() + { + using var process = Process.GetCurrentProcess(); + return $"ClipDiff.ExplorerCommand.{process.SessionId}"; + } } diff --git a/src/ClipDiff.Windows/Explorer/ExplorerCommandProtocol.cs b/src/ClipDiff.Windows/Explorer/ExplorerCommandProtocol.cs index 9a41a98..efe7df3 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerCommandProtocol.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerCommandProtocol.cs @@ -6,68 +6,80 @@ namespace ClipDiff.Windows.Explorer; internal static class ExplorerCommandProtocol { - private const int MaximumPathBytes = 256 * 1024; - private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private const int MaximumPathBytes = 256 * 1024; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); - public static async ValueTask WriteFilePathAsync( - Stream stream, - string filePath, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(stream); - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + public static async ValueTask WriteFilePathAsync( + Stream stream, + string filePath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - var payload = StrictUtf8.GetBytes(filePath); - if (payload.Length > MaximumPathBytes) - { - throw new ArgumentException("The selected file path is too long.", nameof(filePath)); - } + var payload = StrictUtf8.GetBytes(filePath); + if (payload.Length > MaximumPathBytes) + { + throw new ArgumentException("The selected file path is too long.", nameof(filePath)); + } - var header = new byte[sizeof(int)]; - BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length); - await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false); - await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - } + var header = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length); + await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } - public static async ValueTask ReadFilePathAsync( - Stream stream, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(stream); + public static ValueTask WriteDeliveryResultAsync( + Stream stream, bool accepted, CancellationToken cancellationToken = default) => + stream.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, cancellationToken); - var header = new byte[sizeof(int)]; - try - { - await stream.ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false); - } - catch (EndOfStreamException) - { - return null; - } + public static async ValueTask ReadDeliveryResultAsync( + Stream stream, CancellationToken cancellationToken = default) + { + var result = new byte[1]; + await stream.ReadExactlyAsync(result, cancellationToken).ConfigureAwait(false); + return result[0] == 1; + } - var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header); - if (payloadLength <= 0 || payloadLength > MaximumPathBytes) - { - return null; - } + public static async ValueTask ReadFilePathAsync( + Stream stream, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); - var payload = new byte[payloadLength]; - try - { - await stream.ReadExactlyAsync(payload, cancellationToken).ConfigureAwait(false); - var filePath = StrictUtf8.GetString(payload); - return string.IsNullOrWhiteSpace(filePath) || filePath.Contains('\0') - ? null - : filePath; - } - catch (EndOfStreamException) - { - return null; - } - catch (DecoderFallbackException) - { - return null; - } - } + var header = new byte[sizeof(int)]; + try + { + await stream.ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false); + } + catch (EndOfStreamException) + { + return null; + } + + var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header); + if (payloadLength <= 0 || payloadLength > MaximumPathBytes) + { + return null; + } + + var payload = new byte[payloadLength]; + try + { + await stream.ReadExactlyAsync(payload, cancellationToken).ConfigureAwait(false); + var filePath = StrictUtf8.GetString(payload); + return string.IsNullOrWhiteSpace(filePath) || filePath.Contains('\0') + ? null + : filePath; + } + catch (EndOfStreamException) + { + return null; + } + catch (DecoderFallbackException) + { + return null; + } + } } diff --git a/src/ClipDiff.Windows/Explorer/ExplorerCommandServer.cs b/src/ClipDiff.Windows/Explorer/ExplorerCommandServer.cs index 3d14b79..aa65c12 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerCommandServer.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerCommandServer.cs @@ -5,118 +5,163 @@ namespace ClipDiff.Windows.Explorer; internal sealed class ExplorerCommandServer : IDisposable { - private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100); - private readonly Func selectedFileHandler; - private readonly CancellationTokenSource shutdown = new(); - private readonly Lock gate = new(); - private readonly Task listenTask; - private NamedPipeServerStream? activePipe; - private bool disposed; - - public ExplorerCommandServer(Func selectedFileHandler) - { - this.selectedFileHandler = selectedFileHandler ?? - throw new ArgumentNullException(nameof(selectedFileHandler)); - var cancellationToken = shutdown.Token; - listenTask = Task.Run(() => ListenAsync(cancellationToken), CancellationToken.None); - } - - public void Dispose() - { - lock (gate) - { - if (disposed) - { - return; - } - - disposed = true; - shutdown.Cancel(); - activePipe?.Dispose(); - activePipe = null; - } - - _ = listenTask.ContinueWith( - static (_, state) => ((CancellationTokenSource)state!).Dispose(), - shutdown, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - - private async Task ListenAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - using var pipe = new NamedPipeServerStream( - ExplorerCommandClient.GetPipeName(), - PipeDirection.In, - maxNumberOfServerInstances: 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); - SetActivePipe(pipe); - try - { - await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); - var filePath = await ExplorerCommandProtocol.ReadFilePathAsync( - pipe, - cancellationToken).ConfigureAwait(false); - if (filePath is not null) - { - await selectedFileHandler(filePath).ConfigureAwait(false); - } - } - finally - { - ClearActivePipe(pipe); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return; - } - catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) - { - return; - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) - { - try - { - await Task.Delay(RetryDelay, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return; - } - } - } - } - - private void SetActivePipe(NamedPipeServerStream pipe) - { - lock (gate) - { - if (disposed) - { - pipe.Dispose(); - return; - } - - activePipe = pipe; - } - } - - private void ClearActivePipe(NamedPipeServerStream pipe) - { - lock (gate) - { - if (ReferenceEquals(activePipe, pipe)) - { - activePipe = null; - } - } - } + private const int MaximumPendingMessages = 4; + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan DefaultMessageTimeout = TimeSpan.FromSeconds(1); + private readonly Func selectedFileHandler; + private readonly string pipeName; + private readonly TimeSpan messageTimeout; + private readonly CancellationTokenSource shutdown = new(); + private readonly Lock gate = new(); + private readonly HashSet activePipes = []; + private readonly Task listenTask; + private long latestAccepted; + private bool disposed; + + public ExplorerCommandServer(Func handler) + : this(handler, ExplorerCommandClient.GetPipeName(), DefaultMessageTimeout) + { + } + + internal ExplorerCommandServer(Func handler, string name, TimeSpan timeout) + { + selectedFileHandler = handler ?? throw new ArgumentNullException(nameof(handler)); + pipeName = name; + messageTimeout = timeout; + listenTask = Task.Run(() => ListenAsync(shutdown.Token)); + } + + public void Dispose() + { + lock (gate) + { + if (disposed) + { + return; + } + + disposed = true; + shutdown.Cancel(); + + foreach (var pipe in activePipes) + { + pipe.Dispose(); + } + } + + _ = listenTask.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), shutdown, + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + using var slots = new SemaphoreSlim(MaximumPendingMessages); + var receipts = new List(); + long sequence = 0; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + await slots.WaitAsync(cancellationToken).ConfigureAwait(false); + NamedPipeServerStream? pipe = null; + + try + { + pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, + MaximumPendingMessages, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + lock (gate) + { + cancellationToken.ThrowIfCancellationRequested(); + activePipes.Add(pipe); + } + + await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + receipts.RemoveAll(task => task.IsCompleted); + receipts.Add(ReceiveAsync(pipe, ++sequence, slots, cancellationToken)); + pipe = null; // Receipt now owns the pipe and slot. + } + catch + { + if (pipe is not null) + { + RemovePipe(pipe); + pipe.Dispose(); + } + + slots.Release(); + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(RetryDelay, cancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception exception) when (exception is OperationCanceledException or ObjectDisposedException) + { + // Shutdown disposes the listening and receiving pipes. + } + finally + { + await Task.WhenAll(receipts).ConfigureAwait(false); + } + } + + private async Task ReceiveAsync(NamedPipeServerStream pipe, long sequence, + SemaphoreSlim slots, CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(messageTimeout); + + try + { + var filePath = await ExplorerCommandProtocol.ReadFilePathAsync(pipe, deadline.Token).ConfigureAwait(false); + var accepted = false; + + lock (gate) + { + if (!disposed && !deadline.IsCancellationRequested && filePath is not null && sequence > latestAccepted) + { + latestAccepted = sequence; + // The handler queues UI work before returning its task. Never wait + // for file I/O before accepting another Explorer command. + _ = ObserveHandlerAsync(selectedFileHandler(filePath)); + accepted = true; + } + } + + await ExplorerCommandProtocol.WriteDeliveryResultAsync(pipe, accepted, deadline.Token).ConfigureAwait(false); + } + catch (Exception) + { + // Malformed, abandoned, or timed-out clients do not monopolize the listener. + } + finally + { + RemovePipe(pipe); + pipe.Dispose(); + slots.Release(); + } + } + + private static async Task ObserveHandlerAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch (Exception) + { + // A failed command must not stop command receipt or log selected paths. + } + } + + private void RemovePipe(NamedPipeServerStream pipe) + { + lock (gate) + { + activePipes.Remove(pipe); + } + } } diff --git a/src/ClipDiff.Windows/Explorer/ExplorerComparisonRequests.cs b/src/ClipDiff.Windows/Explorer/ExplorerComparisonRequests.cs new file mode 100644 index 0000000..63a05d6 --- /dev/null +++ b/src/ClipDiff.Windows/Explorer/ExplorerComparisonRequests.cs @@ -0,0 +1,111 @@ +using System.IO; +using System.Security; +using ClipDiff.Windows.Clipboard; + +namespace ClipDiff.Windows.Explorer; + +// Start, cancellation and publication run on the UI thread. The reader can +// ignore cancellation; request identity still prevents stale history updates. +internal sealed class ExplorerComparisonRequests +{ + private readonly ClipboardHistory clipboardHistory; + private readonly Func, CancellationToken, ValueTask>> readValues; + private readonly Action started; + private readonly Action publish; + private readonly Action unavailable; + private CancellationTokenSource? active; + + public ExplorerComparisonRequests( + ClipboardHistory history, + Func, CancellationToken, ValueTask>> readFiles, + Action onStarted, + Action onPublish, + Action onUnavailable) + { + clipboardHistory = history; + readValues = readFiles; + started = onStarted; + publish = onPublish; + unavailable = onUnavailable; + } + + public void Cancel() + { + active?.Cancel(); + active = null; + } + + public async Task RunAsync(IReadOnlyList selectedPaths, bool useCurrentCapture) + { + if (!clipboardHistory.IsMonitoring || + selectedPaths.Count != (useCurrentCapture ? 1 : 2) || + (useCurrentCapture && clipboardHistory.Current is null)) + { + unavailable(); + return; + } + + string[] fullPaths; + + try + { + fullPaths = selectedPaths.Select(Path.GetFullPath).ToArray(); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or + PathTooLongException or SecurityException) + { + unavailable(); + return; + } + + Cancel(); + using var cancellation = new CancellationTokenSource(); + active = cancellation; + var sourceId = useCurrentCapture ? clipboardHistory.Current!.Id : (Guid?)null; + started(); + + try + { + var values = await readValues(fullPaths, cancellation.Token); + + if (!ReferenceEquals(active, cancellation) || cancellation.IsCancellationRequested || + !clipboardHistory.IsMonitoring || + (useCurrentCapture && clipboardHistory.Current?.Id != sourceId)) + { + return; + } + + if (values.Count != fullPaths.Length || values.Any(value => value.Text.Length == 0)) + { + unavailable(); + return; + } + + if (useCurrentCapture) + { + var value = values[0]; + clipboardHistory.AcceptDirectText(value.Text, DateTimeOffset.Now, value.FileName, value.FilePath); + } + else + { + var previous = values[0]; + var current = values[1]; + clipboardHistory.AcceptDirectPair(previous.Text, current.Text, DateTimeOffset.Now, + previous.FileName, current.FileName, previous.FilePath, current.FilePath); + } + + publish(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // Superseded, privacy-cleared or shutting down. + } + finally + { + if (ReferenceEquals(active, cancellation)) + { + active = null; + } + } + } +} diff --git a/src/ClipDiff.Windows/Explorer/ExplorerContextCommandLine.cs b/src/ClipDiff.Windows/Explorer/ExplorerContextCommandLine.cs index 18c86b7..8d7285d 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerContextCommandLine.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerContextCommandLine.cs @@ -2,77 +2,51 @@ namespace ClipDiff.Windows.Explorer; internal static class ExplorerContextCommandLine { - public const string CompareWithCurrentSwitch = "--compare-with-current"; - public const string DefaultDisplayName = "Compare with current ClipDiff capture"; - public const string CompareSelectedDisplayName = "Compare two selected files with ClipDiff"; - - public static bool TryGetSelectedFile(IReadOnlyList arguments, out string filePath) - { - ArgumentNullException.ThrowIfNull(arguments); - - if (arguments.Count == 2 && - string.Equals(arguments[0], CompareWithCurrentSwitch, StringComparison.Ordinal) && - !string.IsNullOrWhiteSpace(arguments[1]) && - !arguments[1].Contains('\0')) - { - filePath = arguments[1]; - return true; - } - - filePath = string.Empty; - return false; - } - - public static string BuildShellCommand(string processPath, string? entryAssemblyPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(processPath); - - var command = Quote(processPath); - if (IsDotnetHost(processPath) && - !string.IsNullOrWhiteSpace(entryAssemblyPath)) - { - command += " " + Quote(entryAssemblyPath); - } - - return $"{command} {CompareWithCurrentSwitch} \"%1\""; - } - - public static string BuildComServerCommand(string processPath, string? entryAssemblyPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(processPath); - - var command = Quote(processPath); - if (IsDotnetHost(processPath) && !string.IsNullOrWhiteSpace(entryAssemblyPath)) - { - command += " " + Quote(entryAssemblyPath); - } - - return command; - } - - public static string BuildDisplayName(string? currentSourceFileName) - { - if (string.IsNullOrWhiteSpace(currentSourceFileName)) - { - return DefaultDisplayName; - } - - var separatorIndex = Math.Max( - currentSourceFileName.LastIndexOf('/'), - currentSourceFileName.LastIndexOf('\\')); - var fileName = currentSourceFileName[(separatorIndex + 1)..]; - return fileName.Length == 0 - ? DefaultDisplayName - : $"{DefaultDisplayName} — {fileName}"; - } - - private static bool IsDotnetHost(string processPath) - { - var separatorIndex = Math.Max(processPath.LastIndexOf('/'), processPath.LastIndexOf('\\')); - var fileName = processPath[(separatorIndex + 1)..]; - return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) || - string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase); - } - - private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"")}\""; + public const string CompareWithCurrentSwitch = "--compare-with-current"; + public const string DefaultDisplayName = "Compare with current ClipDiff capture"; + public const string CompareSelectedDisplayName = "Compare two selected files with ClipDiff"; + + public static bool TryGetSelectedFile(IReadOnlyList arguments, out string filePath) + { + ArgumentNullException.ThrowIfNull(arguments); + + if (arguments.Count == 2 && + string.Equals(arguments[0], CompareWithCurrentSwitch, StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(arguments[1]) && + !arguments[1].Contains('\0')) + { + filePath = arguments[1]; + return true; + } + + filePath = string.Empty; + return false; + } + + public static string BuildShellCommand(string processPath, string? entryAssemblyPath) + { + var command = ApplicationCommandLine.Build(processPath, entryAssemblyPath); + return $"{command} {CompareWithCurrentSwitch} \"%1\""; + } + + public static string BuildComServerCommand(string processPath, string? entryAssemblyPath) + { + return ApplicationCommandLine.Build(processPath, entryAssemblyPath); + } + + public static string BuildDisplayName(string? currentSourceFileName) + { + if (string.IsNullOrWhiteSpace(currentSourceFileName)) + { + return DefaultDisplayName; + } + + var separatorIndex = Math.Max( + currentSourceFileName.LastIndexOf('/'), + currentSourceFileName.LastIndexOf('\\')); + var fileName = currentSourceFileName[(separatorIndex + 1)..]; + return fileName.Length == 0 + ? DefaultDisplayName + : $"{DefaultDisplayName} — {fileName}"; + } } diff --git a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs index 9506142..0b3fe7a 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs @@ -1,5 +1,6 @@ using System.IO; using System.Runtime.InteropServices; +using System.Reflection; using System.Security; using Microsoft.Win32; @@ -7,302 +8,332 @@ namespace ClipDiff.Windows.Explorer; internal sealed class ExplorerContextMenuRegistration : IDisposable { - private const string SingleVerbKeyPath = @"Software\Classes\*\shell\ClipDiff.CompareWithCurrent"; - private const string SingleCommandKeyPath = SingleVerbKeyPath + @"\command"; - private const string PairVerbKeyPath = @"Software\Classes\*\shell\ClipDiff.CompareSelected"; - private const string PairHandlerKeyPath = @"Software\Classes\*\shellex\ContextMenuHandlers\ClipDiff.CompareSelected"; - private const string ExtensionClassId = "{6B46A974-40E2-4AD4-9F68-E534202B11E8}"; - private const string ExtensionClassKeyPath = @"Software\Classes\CLSID\" + ExtensionClassId; - private const string ExtensionServerKeyPath = ExtensionClassKeyPath + @"\InprocServer32"; - private const string ReadyEventName = @"Local\ClipDiff.ExplorerPairReady"; - private const string OwnerValueName = "ClipDiffOwner"; - private const string OwnerValue = "ClipDiff.ExplorerContextMenu.v1"; - private const uint ShcneAssocChanged = 0x08000000; - private const uint ShcnfIdList = 0x0000; - private static readonly string ClassIdText = ExplorerDropTargetServer.ClassId.ToString("B").ToUpperInvariant(); - private static readonly string ClassKeyPath = $@"Software\Classes\CLSID\{ClassIdText}"; - private static readonly string LocalServerKeyPath = ClassKeyPath + @"\LocalServer32"; - private readonly string singleCommandLine; - private readonly string comServerCommandLine; - private readonly string iconPath; - private readonly string extensionPath = Path.Combine(AppContext.BaseDirectory, "ClipDiff.ShellExtension.dll"); - private readonly EventWaitHandle? pairReady; - private bool singleStateKnown; - private bool singleEnabled; - private string? singleDisplayName; - private bool pairStateKnown; - private bool pairEnabled; - private bool disposed; - - public ExplorerContextMenuRegistration() - { - var processPath = Environment.ProcessPath; - if (string.IsNullOrWhiteSpace(processPath)) - { - iconPath = string.Empty; - singleCommandLine = string.Empty; - comServerCommandLine = string.Empty; - return; - } - - var entryAssemblyPath = Environment.GetCommandLineArgs().FirstOrDefault(); - iconPath = processPath; - singleCommandLine = ExplorerContextCommandLine.BuildShellCommand(processPath, entryAssemblyPath); - comServerCommandLine = ExplorerContextCommandLine.BuildComServerCommand(processPath, entryAssemblyPath); - try - { - pairReady = new EventWaitHandle(false, EventResetMode.ManualReset, ReadyEventName); - pairReady.Reset(); - } - catch (Exception exception) when (exception is UnauthorizedAccessException or IOException or - WaitHandleCannotBeOpenedException or PlatformNotSupportedException) - { - // The clipboard/tray workflows remain available if native integration is unavailable. - } - } - - public void SetState( - bool monitoringEnabled, - bool hasCurrentCapture, - bool pairHandlerAvailable, - string? currentSourceFileName = null) - { - if (disposed) - { - return; - } - - var changed = UpdateSingleVerb( - monitoringEnabled && hasCurrentCapture, - ExplorerContextCommandLine.BuildDisplayName(currentSourceFileName)); - var pairEnabled = monitoringEnabled && pairHandlerAvailable && pairReady is not null; - if (!pairEnabled) + private const string SingleVerbKeyPath = @"Software\Classes\*\shell\ClipDiff.CompareWithCurrent"; + private const string SingleCommandKeyPath = SingleVerbKeyPath + @"\command"; + private const string PairVerbKeyPath = @"Software\Classes\*\shell\ClipDiff.CompareSelected"; + private const string PairHandlerKeyPath = @"Software\Classes\*\shellex\ContextMenuHandlers\ClipDiff.CompareSelected"; + private const string ExtensionClassId = "{6B46A974-40E2-4AD4-9F68-E534202B11E8}"; + private const string ExtensionClassKeyPath = @"Software\Classes\CLSID\" + ExtensionClassId; + private const string ExtensionServerKeyPath = ExtensionClassKeyPath + @"\InprocServer32"; + private const string ReadyEventName = @"Local\ClipDiff.ExplorerPairReady"; + private const string OwnerValueName = "ClipDiffOwner"; + private const string OwnerValue = "ClipDiff.ExplorerContextMenu.v1"; + private const uint ShcneAssocChanged = 0x08000000; + private const uint ShcnfIdList = 0x0000; + private static readonly string ClassIdText = ExplorerDropTargetServer.ClassId.ToString("B").ToUpperInvariant(); + private static readonly string ClassKeyPath = $@"Software\Classes\CLSID\{ClassIdText}"; + private static readonly string LocalServerKeyPath = ClassKeyPath + @"\LocalServer32"; + private readonly string singleCommandLine; + private readonly string comServerCommandLine; + private readonly string iconPath; + private readonly string extensionPath = Path.Combine(AppContext.BaseDirectory, "ClipDiff.ShellExtension.dll"); + private readonly EventWaitHandle? pairReady; + private readonly ExplorerRegistrationCoordinator? registrationCoordinator; + private bool pairRegistered; + private bool disposed; + + public ExplorerContextMenuRegistration() + { + var processPath = Environment.ProcessPath; + + if (string.IsNullOrWhiteSpace(processPath)) + { + iconPath = string.Empty; + singleCommandLine = string.Empty; + comServerCommandLine = string.Empty; + return; + } + + iconPath = processPath; + singleCommandLine = string.Empty; + comServerCommandLine = string.Empty; + + try + { + var entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; + singleCommandLine = ExplorerContextCommandLine.BuildShellCommand(processPath, entryAssemblyPath); + comServerCommandLine = ExplorerContextCommandLine.BuildComServerCommand(processPath, entryAssemblyPath); + registrationCoordinator = new ExplorerRegistrationCoordinator( + new ExplorerRegistryClaimStore(), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ClipDiff", + "ExplorerOwners")); + } + catch (Exception exception) when (IsRegistryException(exception) || exception is ArgumentException) + { + // The clipboard/tray workflows remain available if native integration is unavailable. + return; + } + + try + { + pairReady = new EventWaitHandle(false, EventResetMode.ManualReset, ReadyEventName); + pairReady.Reset(); + } + catch (Exception exception) when (IsRegistryException(exception) || + exception is WaitHandleCannotBeOpenedException) + { + // A failed pair-ready event does not disable the individual-file command. + } + } + + public void SetState( + bool monitoringEnabled, + bool hasCurrentCapture, + bool pairHandlerAvailable, + string? currentSourceFileName = null) + { + if (disposed) + { + return; + } + + if (registrationCoordinator is null) + { + return; + } + + var pairEnabled = monitoringEnabled && pairHandlerAvailable && pairReady is not null && + comServerCommandLine.Length > 0 && File.Exists(extensionPath) && Environment.Is64BitProcess; + + if (!pairEnabled) { pairReady?.Reset(); } - changed |= UpdatePairVerb(pairEnabled); - if (this.pairEnabled) + try { - pairReady?.Set(); + var claim = new ExplorerRegistrationClaim( + registrationCoordinator.OwnerId, + monitoringEnabled && hasCurrentCapture && singleCommandLine.Length > 0, + ExplorerContextCommandLine.BuildDisplayName(currentSourceFileName), + singleCommandLine, + iconPath, + pairEnabled, + comServerCommandLine, + extensionPath); + var changed = registrationCoordinator.Update(claim, ApplySelection); + + if (pairEnabled && pairRegistered) + { + pairReady?.Set(); + } + else + { + pairReady?.Reset(); + } + + if (changed) + { + NotifyShellChanged(); + } } - else + catch (Exception exception) when (IsRegistryException(exception)) { pairReady?.Reset(); } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + pairReady?.Reset(); + pairReady?.Dispose(); - if (changed) - { - NotifyShellChanged(); - } - } - - public void Dispose() - { - if (disposed) - { - return; - } - - disposed = true; - pairReady?.Reset(); - pairReady?.Dispose(); - var changed = TryRemoveOwnedSingleRegistration(); - changed |= TryRemoveOwnedPairRegistration(); - if (changed) - { - NotifyShellChanged(); - } - } - - private bool UpdateSingleVerb(bool enabled, string displayName) - { - if (singleStateKnown && singleEnabled == enabled && - (!enabled || string.Equals(singleDisplayName, displayName, StringComparison.Ordinal))) - { - return false; - } - - singleStateKnown = true; - if (enabled) - { - singleEnabled = TryRegisterSingle(displayName); - singleDisplayName = singleEnabled ? displayName : null; - return singleEnabled; - } - - singleEnabled = false; - singleDisplayName = null; - return TryRemoveOwnedSingleRegistration(); - } - - private bool UpdatePairVerb(bool enabled) - { - if (pairStateKnown && pairEnabled == enabled) - { - return false; - } - - // Remove the obsolete static verb (including CommandStateHandler) on upgrade, even - // when the previous process crashed or the new native DLL is missing. - var changed = !pairStateKnown && TryRemoveOwnedPairRegistration(); - pairStateKnown = true; - if (enabled) - { - pairEnabled = TryRegisterPair(); - return changed || pairEnabled; - } - - pairEnabled = false; - return TryRemoveOwnedPairRegistration() || changed; - } - - private bool TryRegisterSingle(string displayName) - { - if (singleCommandLine.Length == 0) - { - return false; - } - - try - { - using var verbKey = Registry.CurrentUser.CreateSubKey(SingleVerbKeyPath, writable: true); - using var commandKey = Registry.CurrentUser.CreateSubKey(SingleCommandKeyPath, writable: true); - if (verbKey is null || commandKey is null) - { - return false; - } - - commandKey.SetValue(null, singleCommandLine, RegistryValueKind.String); - verbKey.SetValue(null, displayName, RegistryValueKind.String); - verbKey.SetValue("Icon", $"{Quote(iconPath)},0", RegistryValueKind.String); - verbKey.SetValue("MultiSelectModel", "Single", RegistryValueKind.String); - verbKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - return true; - } - catch (Exception exception) when (IsRegistryException(exception)) - { - TryRemoveOwnedSingleRegistration(); - return false; - } - } - - private bool TryRegisterPair() - { - if (comServerCommandLine.Length == 0 || !File.Exists(extensionPath) || !Environment.Is64BitProcess) - { - return false; - } - - try - { - using var classKey = Registry.CurrentUser.CreateSubKey(ClassKeyPath, writable: true); - classKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - using var localServerKey = Registry.CurrentUser.CreateSubKey(LocalServerKeyPath, writable: true); - classKey.SetValue(null, "ClipDiff Explorer comparison", RegistryValueKind.String); - localServerKey.SetValue(null, comServerCommandLine, RegistryValueKind.String); - - using var extensionClassKey = Registry.CurrentUser.CreateSubKey(ExtensionClassKeyPath, writable: true); - extensionClassKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - extensionClassKey.SetValue(null, "ClipDiff selection menu", RegistryValueKind.String); - using var extensionServerKey = Registry.CurrentUser.CreateSubKey(ExtensionServerKeyPath, writable: true); - extensionServerKey.SetValue(null, extensionPath, RegistryValueKind.String); - extensionServerKey.SetValue("ThreadingModel", "Apartment", RegistryValueKind.String); - - using var handlerKey = Registry.CurrentUser.CreateSubKey(PairHandlerKeyPath, writable: true); - handlerKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - handlerKey.SetValue(null, ExtensionClassId, RegistryValueKind.String); - return true; - } - catch (Exception exception) when (IsRegistryException(exception)) - { - TryRemoveOwnedPairRegistration(); - return false; - } - } - - private bool TryRemoveOwnedSingleRegistration() - { - if (singleCommandLine.Length == 0) - { - return false; - } - - try - { - using var verbKey = Registry.CurrentUser.OpenSubKey(SingleVerbKeyPath, writable: false); - using var commandKey = Registry.CurrentUser.OpenSubKey(SingleCommandKeyPath, writable: false); - var hasOwnerMarker = string.Equals( - verbKey?.GetValue(OwnerValueName) as string, - OwnerValue, - StringComparison.Ordinal); - var hasCurrentCommand = string.Equals( - commandKey?.GetValue(null) as string, - singleCommandLine, - StringComparison.Ordinal); - if (!hasOwnerMarker && !hasCurrentCommand) - { - return false; - } - - verbKey?.Close(); - commandKey?.Close(); - Registry.CurrentUser.DeleteSubKeyTree(SingleVerbKeyPath, throwOnMissingSubKey: false); - return true; - } - catch (Exception exception) when (IsRegistryException(exception)) - { - return false; - } - } - - private bool TryRemoveOwnedPairRegistration() - { - if (comServerCommandLine.Length == 0) - { - return false; - } - - var changed = TryRemoveOwnedKey(PairVerbKeyPath); - changed |= TryRemoveOwnedKey(PairHandlerKeyPath); - changed |= TryRemoveOwnedKey(ExtensionClassKeyPath); - changed |= TryRemoveOwnedKey(ClassKeyPath, LocalServerKeyPath, comServerCommandLine); - return changed; - } - - private static bool TryRemoveOwnedKey(string keyPath, string? commandKeyPath = null, string? currentCommand = null) - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: false); - using var commandKey = commandKeyPath is null ? null : Registry.CurrentUser.OpenSubKey(commandKeyPath); - var owned = string.Equals(key?.GetValue(OwnerValueName) as string, OwnerValue, StringComparison.Ordinal); - var matchesCommand = currentCommand is not null && - string.Equals(commandKey?.GetValue(null) as string, currentCommand, StringComparison.Ordinal); - if (!owned && !matchesCommand) + try + { + if (registrationCoordinator?.Remove(ApplySelection) == true) + { + NotifyShellChanged(); + } + } + catch (Exception exception) when (IsRegistryException(exception)) + { + registrationCoordinator?.Dispose(); + } + } + + private bool ApplySelection(ExplorerRegistrationSelection selection) + { + var changed = selection.Single is null + ? TryRemoveOwnedSingleRegistration() + : TryRegisterSingle( + selection.MultipleOwners + ? ExplorerContextCommandLine.BuildDisplayName(null) + : selection.Single.DisplayName, + selection.Single); + + // The old static two-file verb has no live owner in this registration scheme. + changed |= TryRemoveOwnedKey(PairVerbKeyPath); + pairRegistered = selection.Pair is not null && TryRegisterPair(selection.Pair); + + if (selection.Pair is null) + { + changed |= TryRemoveOwnedPairRegistration(); + } + else + { + changed |= pairRegistered; + } + + return changed; + } + + private bool TryRegisterSingle(string displayName, ExplorerRegistrationClaim claim) + { + if (claim.SingleCommandLine.Length == 0) + { + return false; + } + + try + { + using var verbKey = Registry.CurrentUser.CreateSubKey(SingleVerbKeyPath, writable: true); + using var commandKey = Registry.CurrentUser.CreateSubKey(SingleCommandKeyPath, writable: true); + + if (verbKey is null || commandKey is null) + { + return false; + } + + commandKey.SetValue(null, claim.SingleCommandLine, RegistryValueKind.String); + verbKey.SetValue(null, displayName, RegistryValueKind.String); + verbKey.SetValue("Icon", $"{Quote(claim.IconPath)},0", RegistryValueKind.String); + verbKey.SetValue("MultiSelectModel", "Single", RegistryValueKind.String); + verbKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); + return true; + } + catch (Exception exception) when (IsRegistryException(exception)) + { + TryRemoveOwnedSingleRegistration(); + return false; + } + } + + private bool TryRegisterPair(ExplorerRegistrationClaim claim) + { + if (claim.ComServerCommandLine.Length == 0 || !File.Exists(claim.ExtensionPath) || !Environment.Is64BitProcess) + { + return false; + } + + try + { + using var classKey = Registry.CurrentUser.CreateSubKey(ClassKeyPath, writable: true); + classKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); + using var localServerKey = Registry.CurrentUser.CreateSubKey(LocalServerKeyPath, writable: true); + classKey.SetValue(null, "ClipDiff Explorer comparison", RegistryValueKind.String); + localServerKey.SetValue(null, claim.ComServerCommandLine, RegistryValueKind.String); + + using var extensionClassKey = Registry.CurrentUser.CreateSubKey(ExtensionClassKeyPath, writable: true); + extensionClassKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); + extensionClassKey.SetValue(null, "ClipDiff selection menu", RegistryValueKind.String); + using var extensionServerKey = Registry.CurrentUser.CreateSubKey(ExtensionServerKeyPath, writable: true); + extensionServerKey.SetValue(null, claim.ExtensionPath, RegistryValueKind.String); + extensionServerKey.SetValue("ThreadingModel", "Apartment", RegistryValueKind.String); + + using var handlerKey = Registry.CurrentUser.CreateSubKey(PairHandlerKeyPath, writable: true); + handlerKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); + handlerKey.SetValue(null, ExtensionClassId, RegistryValueKind.String); + return true; + } + catch (Exception exception) when (IsRegistryException(exception)) + { + TryRemoveOwnedPairRegistration(); + return false; + } + } + + private bool TryRemoveOwnedSingleRegistration() + { + if (singleCommandLine.Length == 0) + { + return false; + } + + try + { + using var verbKey = Registry.CurrentUser.OpenSubKey(SingleVerbKeyPath, writable: false); + using var commandKey = Registry.CurrentUser.OpenSubKey(SingleCommandKeyPath, writable: false); + var hasOwnerMarker = string.Equals( + verbKey?.GetValue(OwnerValueName) as string, + OwnerValue, + StringComparison.Ordinal); + var hasCurrentCommand = string.Equals( + commandKey?.GetValue(null) as string, + singleCommandLine, + StringComparison.Ordinal); + + if (!hasOwnerMarker && !hasCurrentCommand) + { + return false; + } + + verbKey?.Close(); + commandKey?.Close(); + Registry.CurrentUser.DeleteSubKeyTree(SingleVerbKeyPath, throwOnMissingSubKey: false); + return true; + } + catch (Exception exception) when (IsRegistryException(exception)) + { + return false; + } + } + + private bool TryRemoveOwnedPairRegistration() + { + if (comServerCommandLine.Length == 0) + { + return false; + } + + var changed = TryRemoveOwnedKey(PairVerbKeyPath); + changed |= TryRemoveOwnedKey(PairHandlerKeyPath); + changed |= TryRemoveOwnedKey(ExtensionClassKeyPath); + changed |= TryRemoveOwnedKey(ClassKeyPath, LocalServerKeyPath, comServerCommandLine); + return changed; + } + + private static bool TryRemoveOwnedKey(string keyPath, string? commandKeyPath = null, string? currentCommand = null) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: false); + using var commandKey = commandKeyPath is null ? null : Registry.CurrentUser.OpenSubKey(commandKeyPath); + var owned = string.Equals(key?.GetValue(OwnerValueName) as string, OwnerValue, StringComparison.Ordinal); + var matchesCommand = currentCommand is not null && + string.Equals(commandKey?.GetValue(null) as string, currentCommand, StringComparison.Ordinal); + + if (!owned && !matchesCommand) { return false; } key?.Close(); - commandKey?.Close(); - Registry.CurrentUser.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false); - return true; - } - catch (Exception exception) when (IsRegistryException(exception)) - { - return false; - } - } - - private static bool IsRegistryException(Exception exception) => - exception is IOException or UnauthorizedAccessException or SecurityException or PlatformNotSupportedException; - - private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"")}\""; - - private static void NotifyShellChanged() => - SHChangeNotify(ShcneAssocChanged, ShcnfIdList, nint.Zero, nint.Zero); - - [DllImport("shell32.dll")] - private static extern void SHChangeNotify(uint eventId, uint flags, nint item1, nint item2); + commandKey?.Close(); + Registry.CurrentUser.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false); + return true; + } + catch (Exception exception) when (IsRegistryException(exception)) + { + return false; + } + } + + private static bool IsRegistryException(Exception exception) => + exception is IOException or UnauthorizedAccessException or SecurityException or PlatformNotSupportedException; + + private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"")}\""; + + private static void NotifyShellChanged() => + SHChangeNotify(ShcneAssocChanged, ShcnfIdList, nint.Zero, nint.Zero); + + [DllImport("shell32.dll")] + private static extern void SHChangeNotify(uint eventId, uint flags, nint item1, nint item2); } diff --git a/src/ClipDiff.Windows/Explorer/ExplorerRegistrationCoordinator.cs b/src/ClipDiff.Windows/Explorer/ExplorerRegistrationCoordinator.cs new file mode 100644 index 0000000..7036887 --- /dev/null +++ b/src/ClipDiff.Windows/Explorer/ExplorerRegistrationCoordinator.cs @@ -0,0 +1,143 @@ +using System.IO; +using ClipDiff.Windows.Ownership; + +namespace ClipDiff.Windows.Explorer; + +internal sealed record ExplorerRegistrationClaim( + string OwnerId, + bool SingleEnabled, + string DisplayName, + string SingleCommandLine, + string IconPath, + bool PairEnabled, + string ComServerCommandLine, + string ExtensionPath); + +internal sealed record ExplorerRegistrationSelection( + ExplorerRegistrationClaim? Single, + ExplorerRegistrationClaim? Pair, + bool MultipleOwners); + +internal interface IExplorerRegistrationClaimStore +{ + IReadOnlyList ReadClaims(); + + void WriteClaim(ExplorerRegistrationClaim claim); + + void DeleteClaim(string ownerId); +} + +internal sealed class ExplorerRegistrationCoordinator : IDisposable +{ + private static readonly TimeSpan CoordinationTimeout = TimeSpan.FromMilliseconds(500); + private readonly IExplorerRegistrationClaimStore claimStore; + private readonly string rootDirectory; + private readonly SharedFileLease sharedFileLease; + private bool disposed; + + public ExplorerRegistrationCoordinator(IExplorerRegistrationClaimStore store, string directory) + { + claimStore = store; + rootDirectory = directory; + OwnerId = Guid.NewGuid().ToString("N"); + sharedFileLease = SharedFileLease.Acquire(GetOwnerPath(OwnerId)); + } + + public string OwnerId { get; } + + public bool Update(ExplorerRegistrationClaim claim, Func apply) + { + if (disposed) + { + return false; + } + + using var gate = SharedFileLease.TryAcquire( + Path.Combine(rootDirectory, "coordination.lock"), + CoordinationTimeout); + + if (gate is null) + { + return false; + } + + claimStore.WriteClaim(claim with { OwnerId = OwnerId }); + return apply(SelectLiveClaims()); + } + + public bool Remove(Func apply) + { + if (disposed) + { + return false; + } + + try + { + using var gate = SharedFileLease.TryAcquire( + Path.Combine(rootDirectory, "coordination.lock"), + CoordinationTimeout); + + if (gate is null) + { + return false; + } + + claimStore.DeleteClaim(OwnerId); + return apply(SelectLiveClaims()); + } + finally + { + Dispose(); + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + sharedFileLease.Dispose(); + } + + private ExplorerRegistrationSelection SelectLiveClaims() + { + var liveClaims = new List(); + + foreach (var claim in claimStore.ReadClaims().OrderBy(claim => claim.OwnerId, StringComparer.Ordinal)) + { + if (!Guid.TryParseExact(claim.OwnerId, "N", out _)) + { + claimStore.DeleteClaim(claim.OwnerId); + continue; + } + + if (claim.OwnerId == OwnerId && !disposed) + { + liveClaims.Add(claim); + continue; + } + + using var staleOwnership = SharedFileLease.TryAcquire(GetOwnerPath(claim.OwnerId)); + + if (staleOwnership is null) + { + liveClaims.Add(claim); + } + else + { + claimStore.DeleteClaim(claim.OwnerId); + } + } + + return new ExplorerRegistrationSelection( + liveClaims.FirstOrDefault(claim => claim.SingleEnabled), + liveClaims.FirstOrDefault(claim => claim.PairEnabled), + liveClaims.Count > 1); + } + + private string GetOwnerPath(string ownerId) => Path.Combine(rootDirectory, ownerId + ".owner"); +} diff --git a/src/ClipDiff.Windows/Explorer/ExplorerRegistryClaimStore.cs b/src/ClipDiff.Windows/Explorer/ExplorerRegistryClaimStore.cs new file mode 100644 index 0000000..2d5059e --- /dev/null +++ b/src/ClipDiff.Windows/Explorer/ExplorerRegistryClaimStore.cs @@ -0,0 +1,55 @@ +using Microsoft.Win32; + +namespace ClipDiff.Windows.Explorer; + +internal sealed class ExplorerRegistryClaimStore : IExplorerRegistrationClaimStore +{ + private const string KeyPath = @"Software\ClipDiff\ExplorerOwners"; + + public IReadOnlyList ReadClaims() + { + using var ownersKey = Registry.CurrentUser.OpenSubKey(KeyPath); + var claims = new List(); + + foreach (var ownerId in ownersKey?.GetSubKeyNames() ?? []) + { + using var ownerKey = ownersKey!.OpenSubKey(ownerId); + + if (ownerKey is null) + { + continue; + } + + claims.Add(new ExplorerRegistrationClaim( + ownerId, + Equals(ownerKey.GetValue("SingleEnabled"), 1), + ReadString(ownerKey, "DisplayName"), + ReadString(ownerKey, "SingleCommandLine"), + ReadString(ownerKey, "IconPath"), + Equals(ownerKey.GetValue("PairEnabled"), 1), + ReadString(ownerKey, "ComServerCommandLine"), + ReadString(ownerKey, "ExtensionPath"))); + } + + return claims; + } + + public void WriteClaim(ExplorerRegistrationClaim claim) + { + using var ownerKey = Registry.CurrentUser.CreateSubKey(KeyPath + @"\" + claim.OwnerId); + ownerKey.SetValue("SingleEnabled", 0, RegistryValueKind.DWord); + ownerKey.SetValue("PairEnabled", 0, RegistryValueKind.DWord); + ownerKey.SetValue("DisplayName", claim.DisplayName, RegistryValueKind.String); + ownerKey.SetValue("SingleCommandLine", claim.SingleCommandLine, RegistryValueKind.String); + ownerKey.SetValue("IconPath", claim.IconPath, RegistryValueKind.String); + ownerKey.SetValue("ComServerCommandLine", claim.ComServerCommandLine, RegistryValueKind.String); + ownerKey.SetValue("ExtensionPath", claim.ExtensionPath, RegistryValueKind.String); + ownerKey.SetValue("SingleEnabled", claim.SingleEnabled ? 1 : 0, RegistryValueKind.DWord); + ownerKey.SetValue("PairEnabled", claim.PairEnabled ? 1 : 0, RegistryValueKind.DWord); + } + + public void DeleteClaim(string ownerId) => + Registry.CurrentUser.DeleteSubKeyTree(KeyPath + @"\" + ownerId, throwOnMissingSubKey: false); + + private static string ReadString(RegistryKey key, string name) => key.GetValue(name) as string ?? string.Empty; +} diff --git a/src/ClipDiff.Windows/ExternalDiff/ExternalDiffLauncher.cs b/src/ClipDiff.Windows/ExternalDiff/ExternalDiffLauncher.cs index 74c4f47..077a472 100644 --- a/src/ClipDiff.Windows/ExternalDiff/ExternalDiffLauncher.cs +++ b/src/ClipDiff.Windows/ExternalDiff/ExternalDiffLauncher.cs @@ -5,187 +5,193 @@ namespace ClipDiff.Windows.ExternalDiff; internal sealed class ExternalDiffLauncher : IDisposable { - private static readonly TimeSpan ProcessExitCleanupDelay = TimeSpan.FromSeconds(3); - private readonly ExternalDiffWorkspace workspace; - private readonly Lock gate = new(); - private readonly Dictionary activeComparisons = []; - private bool disposed; - - public ExternalDiffLauncher(ExternalDiffWorkspace? workspace = null) - { - this.workspace = workspace ?? new ExternalDiffWorkspace(); - this.workspace.CleanupStaleDirectories(); - } - - public bool TryLaunch(ExternalDiffToolChoice choice, ClipboardEntry previous, ClipboardEntry current) - { - ArgumentNullException.ThrowIfNull(choice); - ArgumentNullException.ThrowIfNull(previous); - ArgumentNullException.ThrowIfNull(current); - - if (disposed || !File.Exists(choice.ExecutablePath)) - { - return false; - } - - ExternalDiffFiles? files = null; - Process? process = null; - ActiveComparison? comparison = null; - try - { - files = workspace.Create( - previous.Text, - current.Text, - previous.SourceFileName, - current.SourceFileName); - var startInfo = new ProcessStartInfo - { - FileName = choice.ExecutablePath, - UseShellExecute = false, - WorkingDirectory = files.DirectoryPath - }; - var labels = DiffFormatting.Labels(previous, current); - foreach (var argument in choice.Tool.BuildArguments( - files.PreviousPath, - files.CurrentPath, - labels.Previous, - labels.Current)) - { - startInfo.ArgumentList.Add(argument); - } - - process = Process.Start(startInfo); - if (process is null) - { - ExternalDiffWorkspace.TryDelete(files.DirectoryPath); - return false; - } - - comparison = new ActiveComparison(files.DirectoryPath, process); - process.EnableRaisingEvents = true; - process.Exited += OnProcessExited; - - var registered = false; - lock (gate) - { - if (!disposed) - { - activeComparisons.Add(process, comparison); - registered = true; - } - } - - if (!registered) - { - CleanupComparison(comparison); - return false; - } - - if (process.HasExited) - { - ScheduleCleanup(comparison); - } - - return true; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception) - { - if (comparison is not null) - { - CleanupComparison(comparison); - } - else - { - process?.Dispose(); - if (files is not null) - { - ExternalDiffWorkspace.TryDelete(files.DirectoryPath); - } - } - - return false; - } - } - - public void Dispose() - { - List comparisons; - lock (gate) - { - if (disposed) - { - return; - } - - disposed = true; - comparisons = [.. activeComparisons.Values]; - activeComparisons.Clear(); - } - - foreach (var comparison in comparisons) - { - CleanupComparison(comparison); - } - } - - private void OnProcessExited(object? sender, EventArgs args) - { - if (sender is not Process process) - { - return; - } - - ActiveComparison? comparison; - lock (gate) - { - activeComparisons.TryGetValue(process, out comparison); - } - - if (comparison is not null) - { - ScheduleCleanup(comparison); - } - } - - private async void ScheduleCleanup(ActiveComparison comparison) - { - if (!comparison.TryScheduleCleanup()) - { - return; - } - - await Task.Delay(ProcessExitCleanupDelay).ConfigureAwait(false); - CleanupComparison(comparison); - } - - private void CleanupComparison(ActiveComparison comparison) - { - if (!comparison.TryCompleteCleanup()) - { - return; - } - - lock (gate) - { - activeComparisons.Remove(comparison.Process); - } - - comparison.Process.Exited -= OnProcessExited; - comparison.Process.Dispose(); - ExternalDiffWorkspace.TryDelete(comparison.DirectoryPath); - } - - private sealed class ActiveComparison(string directoryPath, Process process) - { - private int cleanupScheduled; - private int cleanupCompleted; - - public string DirectoryPath { get; } = directoryPath; - - public Process Process { get; } = process; - - public bool TryScheduleCleanup() => Interlocked.Exchange(ref cleanupScheduled, 1) == 0; - - public bool TryCompleteCleanup() => Interlocked.Exchange(ref cleanupCompleted, 1) == 0; - } + private static readonly TimeSpan ProcessExitCleanupDelay = TimeSpan.FromSeconds(3); + private readonly ExternalDiffWorkspace externalDiffWorkspace; + private readonly Lock gate = new(); + private readonly Dictionary activeComparisons = []; + private bool disposed; + + public ExternalDiffLauncher(ExternalDiffWorkspace? workspace = null) + { + externalDiffWorkspace = workspace ?? new ExternalDiffWorkspace(); + externalDiffWorkspace.CleanupStaleDirectories(); + } + + public bool TryLaunch(ExternalDiffToolChoice choice, ClipboardEntry previous, ClipboardEntry current) + { + ArgumentNullException.ThrowIfNull(choice); + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(current); + + if (disposed || !File.Exists(choice.ExecutablePath)) + { + return false; + } + + ExternalDiffFiles? files = null; + Process? process = null; + ActiveComparison? comparison = null; + + try + { + files = externalDiffWorkspace.Create( + previous.Text, + current.Text, + previous.SourceFileName, + current.SourceFileName); + var startInfo = new ProcessStartInfo + { + FileName = choice.ExecutablePath, + UseShellExecute = false, + WorkingDirectory = files.DirectoryPath + }; + var labels = DiffFormatting.Labels(previous, current); + + foreach (var argument in choice.Tool.BuildArguments( + files.PreviousPath, + files.CurrentPath, + labels.Previous, + labels.Current)) + { + startInfo.ArgumentList.Add(argument); + } + + process = Process.Start(startInfo); + + if (process is null) + { + files.Dispose(); + return false; + } + + comparison = new ActiveComparison(files, process); + process.EnableRaisingEvents = true; + process.Exited += OnProcessExited; + + var registered = false; + + lock (gate) + { + if (!disposed) + { + activeComparisons.Add(process, comparison); + registered = true; + } + } + + if (!registered) + { + CleanupComparison(comparison); + return false; + } + + if (process.HasExited) + { + ScheduleCleanup(comparison); + } + + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + if (comparison is not null) + { + CleanupComparison(comparison); + } + else + { + process?.Dispose(); + if (files is not null) + { + files.Dispose(); + } + } + + return false; + } + } + + public void Dispose() + { + List comparisons; + + lock (gate) + { + if (disposed) + { + return; + } + + disposed = true; + comparisons = [.. activeComparisons.Values]; + activeComparisons.Clear(); + } + + foreach (var comparison in comparisons) + { + CleanupComparison(comparison); + } + } + + private void OnProcessExited(object? sender, EventArgs args) + { + if (sender is not Process process) + { + return; + } + + ActiveComparison? comparison; + + lock (gate) + { + activeComparisons.TryGetValue(process, out comparison); + } + + if (comparison is not null) + { + ScheduleCleanup(comparison); + } + } + + private async void ScheduleCleanup(ActiveComparison comparison) + { + if (!comparison.TryScheduleCleanup()) + { + return; + } + + await Task.Delay(ProcessExitCleanupDelay).ConfigureAwait(false); + CleanupComparison(comparison); + } + + private void CleanupComparison(ActiveComparison comparison) + { + if (!comparison.TryCompleteCleanup()) + { + return; + } + + lock (gate) + { + activeComparisons.Remove(comparison.Process); + } + + comparison.Process.Exited -= OnProcessExited; + comparison.Process.Dispose(); + comparison.Files.Dispose(); + } + + private sealed class ActiveComparison(ExternalDiffFiles files, Process process) + { + private int cleanupScheduled; + private int cleanupCompleted; + + public ExternalDiffFiles Files { get; } = files; + + public Process Process { get; } = process; + + public bool TryScheduleCleanup() => Interlocked.Exchange(ref cleanupScheduled, 1) == 0; + + public bool TryCompleteCleanup() => Interlocked.Exchange(ref cleanupCompleted, 1) == 0; + } } diff --git a/src/ClipDiff.Windows/ExternalDiff/ExternalDiffWorkspace.cs b/src/ClipDiff.Windows/ExternalDiff/ExternalDiffWorkspace.cs index e45b78c..2a74d88 100644 --- a/src/ClipDiff.Windows/ExternalDiff/ExternalDiffWorkspace.cs +++ b/src/ClipDiff.Windows/ExternalDiff/ExternalDiffWorkspace.cs @@ -1,127 +1,167 @@ using System.IO; using System.Text; +using ClipDiff.Windows.Ownership; namespace ClipDiff.Windows.ExternalDiff; -internal sealed record ExternalDiffFiles(string DirectoryPath, string PreviousPath, string CurrentPath); +internal sealed class ExternalDiffFiles( + string directoryPath, + string previousPath, + string currentPath, + SharedFileLease lease) : IDisposable +{ + private SharedFileLease? sharedFileLease = lease; + + public string DirectoryPath { get; } = directoryPath; + + public string PreviousPath { get; } = previousPath; + + public string CurrentPath { get; } = currentPath; + + public void Dispose() + { + var ownership = Interlocked.Exchange(ref sharedFileLease, null); + + if (ownership is null) + { + return; + } + + ExternalDiffWorkspace.TryDelete(DirectoryPath); + ownership.Dispose(); + } +} internal sealed class ExternalDiffWorkspace { - private static readonly UTF8Encoding Utf8WithByteOrderMark = new(true); - private readonly string rootDirectory; - - public ExternalDiffWorkspace(string? rootDirectory = null) - { - this.rootDirectory = rootDirectory ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "ClipDiff", - "Temp"); - } - - public ExternalDiffFiles Create( - string previousText, - string currentText, - string? previousSourceFileName = null, - string? currentSourceFileName = null) - { - ArgumentNullException.ThrowIfNull(previousText); - ArgumentNullException.ThrowIfNull(currentText); - - Directory.CreateDirectory(rootDirectory); - var directory = Path.Combine(rootDirectory, Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - - try - { - var previousPath = CreateSidePath( - directory, - "Previous", - "Previous clipboard.txt", - previousSourceFileName); - var currentPath = CreateSidePath( - directory, - "Current", - "Current clipboard.txt", - currentSourceFileName); - File.WriteAllText(previousPath, previousText, Utf8WithByteOrderMark); - File.WriteAllText(currentPath, currentText, Utf8WithByteOrderMark); - File.SetAttributes(previousPath, FileAttributes.ReadOnly | FileAttributes.Temporary); - File.SetAttributes(currentPath, FileAttributes.ReadOnly | FileAttributes.Temporary); - return new ExternalDiffFiles(directory, previousPath, currentPath); - } - catch - { - TryDelete(directory); - throw; - } - } - - private static string CreateSidePath( - string comparisonDirectory, - string side, - string defaultFileName, - string? sourceFileName) - { - if (string.IsNullOrWhiteSpace(sourceFileName)) - { - return Path.Combine(comparisonDirectory, defaultFileName); - } - - var sideDirectory = Path.Combine(comparisonDirectory, side); - Directory.CreateDirectory(sideDirectory); - return Path.Combine(sideDirectory, SanitizeFileName(sourceFileName, defaultFileName)); - } - - private static string SanitizeFileName(string fileName, string fallback) - { - var separatorIndex = fileName.LastIndexOfAny(['\\', '/']); - var name = separatorIndex >= 0 ? fileName[(separatorIndex + 1)..] : fileName; - var sanitized = new string(name - .Select(character => character < ' ' || "<>:\"/\\|?*".Contains(character) ? '_' : character) - .ToArray()) - .TrimEnd(' ', '.'); - return sanitized.Length == 0 ? fallback : sanitized; - } - - public void CleanupStaleDirectories() - { - if (!Directory.Exists(rootDirectory)) - { - return; - } - - try - { - foreach (var directory in Directory.EnumerateDirectories(rootDirectory)) - { - TryDelete(directory); - } - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - } - } - - public static bool TryDelete(string directory) - { - if (!Directory.Exists(directory)) - { - return true; - } - - try - { - foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - - Directory.Delete(directory, true); - return true; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - return false; - } - } + private static readonly UTF8Encoding Utf8WithByteOrderMark = new(true); + private readonly string rootDirectory; + + public ExternalDiffWorkspace(string? directory = null) + { + rootDirectory = directory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ClipDiff", + "Temp"); + } + + public ExternalDiffFiles Create( + string previousText, + string currentText, + string? previousSourceFileName = null, + string? currentSourceFileName = null) + { + ArgumentNullException.ThrowIfNull(previousText); + ArgumentNullException.ThrowIfNull(currentText); + + Directory.CreateDirectory(rootDirectory); + var directory = Path.Combine(rootDirectory, Guid.NewGuid().ToString("N")); + var ownership = SharedFileLease.Acquire(directory + ".owner"); + + try + { + Directory.CreateDirectory(directory); + + var previousPath = CreateSidePath( + directory, + "Previous", + "Previous clipboard.txt", + previousSourceFileName); + var currentPath = CreateSidePath( + directory, + "Current", + "Current clipboard.txt", + currentSourceFileName); + + File.WriteAllText(previousPath, previousText, Utf8WithByteOrderMark); + File.WriteAllText(currentPath, currentText, Utf8WithByteOrderMark); + File.SetAttributes(previousPath, FileAttributes.ReadOnly | FileAttributes.Temporary); + File.SetAttributes(currentPath, FileAttributes.ReadOnly | FileAttributes.Temporary); + + return new ExternalDiffFiles(directory, previousPath, currentPath, ownership); + } + catch + { + TryDelete(directory); + ownership.Dispose(); + throw; + } + } + + private static string CreateSidePath( + string comparisonDirectory, + string side, + string defaultFileName, + string? sourceFileName) + { + if (string.IsNullOrWhiteSpace(sourceFileName)) + { + return Path.Combine(comparisonDirectory, defaultFileName); + } + + var sideDirectory = Path.Combine(comparisonDirectory, side); + Directory.CreateDirectory(sideDirectory); + return Path.Combine(sideDirectory, SanitizeFileName(sourceFileName, defaultFileName)); + } + + private static string SanitizeFileName(string fileName, string fallback) + { + var separatorIndex = fileName.LastIndexOfAny(['\\', '/']); + var name = separatorIndex >= 0 ? fileName[(separatorIndex + 1)..] : fileName; + var sanitized = new string(name + .Select(character => character < ' ' || "<>:\"/\\|?*".Contains(character) ? '_' : character) + .ToArray()) + .TrimEnd(' ', '.'); + + return sanitized.Length == 0 ? fallback : sanitized; + } + + public void CleanupStaleDirectories() + { + if (!Directory.Exists(rootDirectory)) + { + return; + } + + try + { + foreach (var directory in Directory.EnumerateDirectories(rootDirectory)) + { + // The marker lives beside the directory so it can remain exclusively + // open throughout deletion. A live comparison cannot be acquired. + using var ownership = SharedFileLease.TryAcquire(directory + ".owner"); + + if (ownership is not null) + { + TryDelete(directory); + } + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + public static bool TryDelete(string directory) + { + if (!Directory.Exists(directory)) + { + return true; + } + + try + { + foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(directory, true); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } } diff --git a/src/ClipDiff.Windows/Ownership/SharedFileLease.cs b/src/ClipDiff.Windows/Ownership/SharedFileLease.cs new file mode 100644 index 0000000..45b70bc --- /dev/null +++ b/src/ClipDiff.Windows/Ownership/SharedFileLease.cs @@ -0,0 +1,46 @@ +using System.IO; + +namespace ClipDiff.Windows.Ownership; + +internal sealed class SharedFileLease : IDisposable +{ + private readonly FileStream fileStream; + + private SharedFileLease(FileStream stream) + { + fileStream = stream; + } + + public static SharedFileLease Acquire(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + return new SharedFileLease(new FileStream( + path, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose)); + } + + public static SharedFileLease? TryAcquire(string path) + { + try + { + return Acquire(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + + public static SharedFileLease? TryAcquire(string path, TimeSpan timeout) + { + SharedFileLease? ownership = null; + SpinWait.SpinUntil(() => (ownership = TryAcquire(path)) is not null, timeout); + return ownership; + } + + public void Dispose() => fileStream.Dispose(); +} diff --git a/src/ClipDiff.Windows/Startup/StartupRegistration.cs b/src/ClipDiff.Windows/Startup/StartupRegistration.cs index 679ba7c..4fcdbbb 100644 --- a/src/ClipDiff.Windows/Startup/StartupRegistration.cs +++ b/src/ClipDiff.Windows/Startup/StartupRegistration.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Reflection; using System.Security; using Microsoft.Win32; @@ -6,7 +7,8 @@ namespace ClipDiff.Windows.Startup; internal sealed class StartupRegistration( string registryPath = @"Software\Microsoft\Windows\CurrentVersion\Run", - string? executablePath = null) + string? executablePath = null, + string? entryAssemblyPath = null) { private const string ValueName = "ClipDiff"; @@ -33,14 +35,13 @@ public bool TrySetEnabled(bool enabled) if (enabled) { var path = executablePath ?? Environment.ProcessPath; + var assemblyPath = entryAssemblyPath ?? Assembly.GetEntryAssembly()?.Location; - if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.Contains('"')) + if (!ApplicationCommandLine.TryBuild(path, assemblyPath, out var command)) { return false; } - var command = $"\"{path}\""; - // Windows limits Run value command lines to 260 characters. if (command.Length > 260) { diff --git a/src/ClipDiff.Windows/ViewModels/ComparisonPresentation.cs b/src/ClipDiff.Windows/ViewModels/ComparisonPresentation.cs new file mode 100644 index 0000000..ede6f18 --- /dev/null +++ b/src/ClipDiff.Windows/ViewModels/ComparisonPresentation.cs @@ -0,0 +1,35 @@ +namespace ClipDiff.Windows.ViewModels; + +// UI-thread only. Native confirmation dialogs run a nested message loop, so +// clipboard invalidation and a newer Show Diff can occur before they return. +internal sealed class ComparisonPresentation +{ + private long generation; + + public void Cancel() => generation++; + + public void Show(Func? confirm, Func launch, Action showBuiltIn) + { + var request = ++generation; + + if (confirm is not null) + { + var confirmed = confirm(); + + if (request != generation) + { + return; + } + + if (confirmed && launch()) + { + return; + } + } + + if (request == generation) + { + showBuiltIn(); + } + } +} diff --git a/tests/ClipDiff.Core.Tests/DiffEngineBudgetTests.cs b/tests/ClipDiff.Core.Tests/DiffEngineBudgetTests.cs new file mode 100644 index 0000000..5596348 --- /dev/null +++ b/tests/ClipDiff.Core.Tests/DiffEngineBudgetTests.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Core.Tests; + +[TestClass] +public sealed class DiffEngineBudgetTests +{ + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public void TenThousandUnrelatedLinesStayWithinAllocationBudget() + { + const int lineCount = 10_000; + var previous = Entry(Lines("old", lineCount)); + var current = Entry(Lines("new", lineCount)); + var engine = new DiffEngine(); + engine.Compare(Entry("warmup old"), Entry("warmup new")); + + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + var document = engine.Compare(previous, current); + stopwatch.Stop(); + + long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + TestContext.WriteLine($"10,000 unrelated lines: {allocated:N0} allocated bytes, {stopwatch.Elapsed.TotalMilliseconds:N1} ms."); + + // The line-search frontier and trace are capped at 8 MiB. This includes the + // complete document and bounded inline highlighting, with room for runtimes. + Assert.IsLessThan(64L * 1024 * 1024, allocated); + Assert.AreEqual(new DiffSummary(0, 0, lineCount, 0), document.Summary); + AssertPreservesLines(previous, current, document); + } + + [TestMethod] + public void BudgetFallbackKeepsExactPrefixAndSuffixAndIsDeterministic() + { + var previous = Entry($"prefix\n{Lines("old", 10_000)}\nsuffix"); + var current = Entry($"prefix\n{Lines("new", 10_000)}\nsuffix"); + var engine = new DiffEngine(); + var first = engine.Compare(previous, current); + var second = engine.Compare(previous, current); + + Assert.AreEqual(new DiffSummary(0, 0, 10_000, 2), first.Summary); + Assert.AreEqual(DiffKind.Equal, first.Rows[0].Kind); + Assert.AreEqual(DiffKind.Equal, first.Rows[^1].Kind); + AssertPreservesLines(previous, current, first); + CollectionAssert.AreEqual( + first.Rows.Select(RowSignature).ToArray(), + second.Rows.Select(RowSignature).ToArray()); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void TenThousandWhollyInsertedOrDeletedLinesKeepLineNumbers(bool insert) + { + var shorter = Entry("prefix\nsuffix"); + var longer = Entry($"prefix\n{Lines("extra", 10_000)}\nsuffix"); + var previous = insert ? shorter : longer; + var current = insert ? longer : shorter; + var document = new DiffEngine().Compare(previous, current); + + Assert.AreEqual(insert + ? new DiffSummary(10_000, 0, 0, 2) + : new DiffSummary(0, 10_000, 0, 2), document.Summary); + Assert.AreEqual(insert ? DiffKind.Inserted : DiffKind.Removed, document.Rows[1].Kind); + Assert.AreEqual(insert ? 2 : (int?)null, document.Rows[1].NewLineNumber); + Assert.AreEqual(insert ? (int?)null : 2, document.Rows[1].OldLineNumber); + AssertPreservesLines(previous, current, document); + } + + [TestMethod] + public void CancellationDuringLargeEditBlockStopsRowGeneration() + { + using var cancellation = new CancellationTokenSource(); + var generatedRows = 0; + var engine = new DiffEngine(() => + { + generatedRows++; + + if (generatedRows == 50) + { + cancellation.Cancel(); + } + + return Guid.NewGuid(); + }); + var previous = Entry(Lines("old", 10_000)); + var current = Entry(Lines("new", 10_000)); + + Assert.ThrowsExactly(() => + engine.Compare(previous, current, cancellationToken: cancellation.Token)); + Assert.AreEqual(50, generatedRows); + } + + private static void AssertPreservesLines(ClipboardEntry previous, ClipboardEntry current, DiffDocument document) + { + var oldRows = document.Rows.Where(row => row.OldLineNumber is not null).ToArray(); + var newRows = document.Rows.Where(row => row.NewLineNumber is not null).ToArray(); + + CollectionAssert.AreEqual(TextLines.Split(previous.Text), oldRows.Select(row => row.OldText).ToArray()); + CollectionAssert.AreEqual(TextLines.Split(current.Text), newRows.Select(row => row.NewText).ToArray()); + CollectionAssert.AreEqual(Enumerable.Range(1, oldRows.Length).ToArray(), oldRows.Select(row => row.OldLineNumber!.Value).ToArray()); + CollectionAssert.AreEqual(Enumerable.Range(1, newRows.Length).ToArray(), newRows.Select(row => row.NewLineNumber!.Value).ToArray()); + } + + private static ClipboardEntry Entry(string text) => new(Guid.NewGuid(), text, DateTimeOffset.UnixEpoch); + + private static string Lines(string prefix, int count) => string.Join('\n', Enumerable.Range(0, count).Select(index => $"{prefix}-{index}")); + + private static string RowSignature(DiffRow row) => $"{row.OldLineNumber}|{row.NewLineNumber}|{row.OldText}|{row.NewText}|{row.Kind}"; +} diff --git a/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs b/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs new file mode 100644 index 0000000..ecd121a --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs @@ -0,0 +1,52 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ApplicationCommandLineTests +{ + [TestMethod] + public void ExecutableCommandQuotesSpacesAndUnicode() + { + var command = ApplicationCommandLine.Build(@"C:\Tools with spaces\日本語\ClipDiff.exe", null); + Assert.AreEqual("\"C:\\Tools with spaces\\日本語\\ClipDiff.exe\"", command); + } + + [TestMethod] + [DataRow(@"C:\Program Files\dotnet\dotnet.exe")] + [DataRow(@"C:\Program Files\dotnet\DOTNET.EXE")] + [DataRow(@"/usr/local/share/dotnet/dotnet")] + public void HostedCommandIncludesQuotedEntryAssembly(string processPath) + { + var command = ApplicationCommandLine.Build(processPath, @"C:\ClipDiff build\日本語\ClipDiff.dll"); + Assert.AreEqual($"\"{processPath}\" \"C:\\ClipDiff build\\日本語\\ClipDiff.dll\"", command); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("ClipDiff.dll")] + [DataRow("C:\\bad\"path\\ClipDiff.dll")] + public void HostedCommandRejectsMissingOrInvalidEntryAssembly(string? entryAssemblyPath) + { + Assert.IsFalse(ApplicationCommandLine.TryBuild(@"C:\Program Files\dotnet\dotnet.exe", entryAssemblyPath, out _)); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("ClipDiff.exe")] + [DataRow("C:\\bad\"path.exe")] + [DataRow("C:\\bad\0path.exe")] + public void CommandRejectsInvalidExecutable(string? processPath) + { + Assert.IsFalse(ApplicationCommandLine.TryBuild(processPath, null, out _)); + } + + [TestMethod] + public void DirectExecutableDoesNotIncludeAnEntryAssemblyArgument() + { + var command = ApplicationCommandLine.Build(@"C:\ClipDiff\ClipDiff.exe", @"C:\ClipDiff\ClipDiff.dll"); + Assert.AreEqual("\"C:\\ClipDiff\\ClipDiff.exe\"", command); + } +} diff --git a/tests/ClipDiff.Windows.Tests/ComparisonRequestTests.cs b/tests/ClipDiff.Windows.Tests/ComparisonRequestTests.cs new file mode 100644 index 0000000..50c9993 --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ComparisonRequestTests.cs @@ -0,0 +1,171 @@ +using ClipDiff.Windows.Clipboard; +using ClipDiff.Windows.Explorer; +using ClipDiff.Windows.ViewModels; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ComparisonRequestTests +{ + [TestMethod] + public async Task SlowPairCannotReplaceNewerPairEvenWhenReaderIgnoresCancellation() + { + var history = new ClipboardHistory(); + var first = new TaskCompletionSource>(); + var second = new TaskCompletionSource>(); + var reads = 0; + var publications = 0; + CancellationToken firstToken = default; + var requests = new ExplorerComparisonRequests(history, (_, token) => + { + if (++reads == 1) + { + firstToken = token; + return new(first.Task); + } + + return new(second.Task); + }, () => { }, () => publications++, () => Assert.Fail("Unexpected rejection.")); + + var older = requests.RunAsync(["old-a.txt", "old-b.txt"], false); + var newer = requests.RunAsync(["new-a.txt", "new-b.txt"], false); + Assert.IsTrue(firstToken.IsCancellationRequested); + second.SetResult([Value("new-a"), Value("new-b")]); + await newer; + first.SetResult([Value("old-a"), Value("old-b")]); + await older; + + Assert.AreEqual("new-a", history.Previous?.Text); + Assert.AreEqual("new-b", history.Current?.Text); + Assert.AreEqual(1, publications); + } + + [TestMethod] + public async Task SingleFileRetainsSourceIdentityAcrossClipboardChange() + { + var history = new ClipboardHistory(); + history.AcceptDirectText("A", DateTimeOffset.Now); + var pending = new TaskCompletionSource>(); + var publications = 0; + var requests = new ExplorerComparisonRequests(history, (_, _) => new(pending.Task), + () => { }, () => publications++, () => Assert.Fail("Unexpected rejection.")); + + var run = requests.RunAsync(["B.txt"], true); + history.Apply(ClipboardObservation.TextValue(1, DateTimeOffset.Now, "C")); + pending.SetResult([Value("B")]); + await run; + + Assert.AreEqual("A", history.Previous?.Text); + Assert.AreEqual("C", history.Current?.Text); + Assert.AreEqual(0, publications); + } + + [TestMethod] + public async Task CancelledFileReadCannotRestoreAutomaticallyClearedCapture() + { + var history = new ClipboardHistory(); + var now = DateTimeOffset.Now; + history.Apply(ClipboardObservation.TextValue(1, now, "older")); + history.Apply(ClipboardObservation.TextValue(2, now, "secret")); + var pending = new TaskCompletionSource>(); + var requests = new ExplorerComparisonRequests(history, (_, _) => new(pending.Task), + () => { }, () => Assert.Fail("Cleared text was published."), () => { }); + + var run = requests.RunAsync(["selected.txt"], true); + Assert.AreEqual(ClipboardHistoryChange.RemovedByRecentClear, + history.Apply(ClipboardObservation.ExplicitClear(3, now.AddSeconds(1)))); + requests.Cancel(); + pending.SetResult([Value("selected")]); + await run; + + Assert.AreEqual("older", history.Current?.Text); + Assert.IsNull(history.Previous); + } + + [TestMethod] + public async Task NewSingleFileSupersedesPendingPair() + { + var history = new ClipboardHistory(); + history.AcceptDirectText("source", DateTimeOffset.Now); + var pending = new TaskCompletionSource>(); + var reads = 0; + var requests = new ExplorerComparisonRequests(history, + (_, _) => ++reads == 1 ? new(pending.Task) : new(new[] { Value("selected") }), + () => { }, () => { }, () => Assert.Fail("Unexpected rejection.")); + + var oldPair = requests.RunAsync(["a.txt", "b.txt"], false); + await requests.RunAsync(["selected.txt"], true); + pending.SetResult([Value("stale-a"), Value("stale-b")]); + await oldPair; + + Assert.AreEqual("source", history.Previous?.Text); + Assert.AreEqual("selected", history.Current?.Text); + } + + [TestMethod] + public async Task PausedOrMissingSourceNeverReadsSelectedFile() + { + var history = new ClipboardHistory(); + var rejected = 0; + var requests = new ExplorerComparisonRequests(history, + (_, _) => throw new AssertFailedException("Unexpected file I/O."), + () => { }, () => { }, () => rejected++); + + await requests.RunAsync(["selected.txt"], true); + history.Pause(); + await requests.RunAsync(["a.txt", "b.txt"], false); + Assert.AreEqual(2, rejected); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void WarningCannotPublishAfterRecentClearWhetherAcceptedOrCancelled(bool accepted) + { + var now = DateTimeOffset.Now; + var history = new ClipboardHistory(); + history.Apply(ClipboardObservation.TextValue(1, now, "older")); + history.Apply(ClipboardObservation.TextValue(2, now, "secret")); + var presentation = new ComparisonPresentation(); + var launches = 0; + var builtIn = 0; + + presentation.Show(() => + { + Assert.AreEqual(ClipboardHistoryChange.RemovedByRecentClear, + history.Apply(ClipboardObservation.ExplicitClear(3, now.AddSeconds(1)))); + presentation.Cancel(); + return accepted; + }, () => { launches++; return true; }, () => builtIn++); + + Assert.AreEqual(0, launches); + Assert.AreEqual(0, builtIn); + } + + [TestMethod] + public void NestedNewComparisonPreventsOlderWarningFallback() + { + var presentation = new ComparisonPresentation(); + var shown = new List(); + + presentation.Show(() => + { + presentation.Show(null, () => false, () => shown.Add("new")); + return false; + }, () => false, () => shown.Add("old")); + + CollectionAssert.AreEqual(new[] { "new" }, shown); + } + + [TestMethod] + public void OrdinaryWarningCancellationStillUsesBuiltInViewer() + { + var shown = false; + new ComparisonPresentation().Show(() => false, + () => throw new AssertFailedException("Unexpected external launch."), () => shown = true); + Assert.IsTrue(shown); + } + + private static CopiedFileText Value(string text) => new(text, text + ".txt", "/files/" + text + ".txt"); +} diff --git a/tests/ClipDiff.Windows.Tests/ExplorerCommandServerTests.cs b/tests/ClipDiff.Windows.Tests/ExplorerCommandServerTests.cs new file mode 100644 index 0000000..ce4f957 --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ExplorerCommandServerTests.cs @@ -0,0 +1,93 @@ +using System.IO.Pipes; +using ClipDiff.Windows.Explorer; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ExplorerCommandServerTests +{ + [TestMethod] + public async Task SlowHandlerDoesNotBlockNextDelivery() + { + var name = Guid.NewGuid().ToString("N")[..12]; + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var count = 0; + using var server = new ExplorerCommandServer(path => + { + if (Interlocked.Increment(ref count) == 1) + { + first.SetResult(); + return pending.Task; + } + + second.SetResult(path); + return Task.CompletedTask; + }, name, TimeSpan.FromSeconds(1)); + + try + { + Assert.IsTrue(await ExplorerCommandClient.TrySendSelectedFileAsync("first.txt", name)); + await first.Task.WaitAsync(TimeSpan.FromSeconds(3)); + await Task.Delay(TimeSpan.FromMilliseconds(2100)); + Assert.IsTrue(await ExplorerCommandClient.TrySendSelectedFileAsync("second.txt", name)); + Assert.AreEqual("second.txt", await second.Task.WaitAsync(TimeSpan.FromSeconds(3))); + Assert.IsFalse(pending.Task.IsCompleted); + } + finally + { + pending.TrySetResult(); + } + } + + [TestMethod] + public async Task PartialClientDoesNotBlockHealthyClientAndIsDisconnectedAtDeadline() + { + var name = Guid.NewGuid().ToString("N")[..12]; + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var server = new ExplorerCommandServer(path => + { + received.TrySetResult(path); + return Task.CompletedTask; + }, name, TimeSpan.FromMilliseconds(300)); + using var partial = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await partial.ConnectAsync(timeout.Token); + await partial.WriteAsync(new byte[] { 5, 0 }, timeout.Token); + + Assert.IsTrue(await ExplorerCommandClient.TrySendSelectedFileAsync("healthy.txt", name)); + Assert.AreEqual("healthy.txt", await received.Task.WaitAsync(timeout.Token)); + var buffer = new byte[1]; + Assert.AreEqual(0, await partial.ReadAsync(buffer, timeout.Token)); + } + + [TestMethod] + public async Task OlderPartialMessageCannotSupersedeNewerCompleteRequest() + { + var name = Guid.NewGuid().ToString("N")[..12]; + var calls = 0; + using var server = new ExplorerCommandServer(_ => + { + Interlocked.Increment(ref calls); + return Task.CompletedTask; + }, name, TimeSpan.FromSeconds(1)); + using var older = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await older.ConnectAsync(timeout.Token); + await older.WriteAsync(new byte[] { 3, 0, 0, 0 }, timeout.Token); + Assert.IsTrue(await ExplorerCommandClient.TrySendSelectedFileAsync("newer.txt", name)); + await older.WriteAsync("old"u8.ToArray(), timeout.Token); + + Assert.IsFalse(await ExplorerCommandProtocol.ReadDeliveryResultAsync(older, timeout.Token)); + Assert.AreEqual(1, calls); + } + + [TestMethod] + public async Task MissingServerReportsDeliveryFailure() + { + Assert.IsFalse(await ExplorerCommandClient.TrySendSelectedFileAsync( + "selected.txt", Guid.NewGuid().ToString("N")[..12])); + } +} diff --git a/tests/ClipDiff.Windows.Tests/ExplorerRegistrationCoordinatorTests.cs b/tests/ClipDiff.Windows.Tests/ExplorerRegistrationCoordinatorTests.cs new file mode 100644 index 0000000..f491ccb --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ExplorerRegistrationCoordinatorTests.cs @@ -0,0 +1,258 @@ +using ClipDiff.Windows.Explorer; +using ClipDiff.Windows.ExternalDiff; +using ClipDiff.Windows.Ownership; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ExplorerRegistrationCoordinatorTests +{ + [TestMethod] + public void StartingAndQuittingSecondOwnerPreservesFirstOwnersRegistration() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var first = new ExplorerRegistrationCoordinator(store, directory); + using var second = new ExplorerRegistrationCoordinator(store, directory); + ExplorerRegistrationSelection? selection = null; + + bool Apply(ExplorerRegistrationSelection value) + { + selection = value; + return true; + } + + first.Update(CreateClaim("first", enabled: true), Apply); + second.Update(CreateClaim("second", enabled: false), Apply); + + Assert.AreEqual(first.OwnerId, selection!.Single!.OwnerId); + Assert.AreEqual(first.OwnerId, selection.Pair!.OwnerId); + Assert.IsTrue(selection.MultipleOwners); + + second.Remove(Apply); + + Assert.AreEqual(first.OwnerId, selection.Single!.OwnerId); + Assert.AreEqual("first", selection.Single.DisplayName); + Assert.AreEqual(first.OwnerId, selection.Pair!.OwnerId); + Assert.IsFalse(selection.MultipleOwners); + + first.Remove(Apply); + + Assert.IsNull(selection.Single); + Assert.IsNull(selection.Pair); + Assert.AreEqual(0, store.ReadClaims().Count); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + [TestMethod] + public void QuittingSelectedOwnerRestoresSurvivingOwnersCommandsAndLabels() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var first = new ExplorerRegistrationCoordinator(store, directory); + using var second = new ExplorerRegistrationCoordinator(store, directory); + ExplorerRegistrationSelection? selection = null; + + bool Apply(ExplorerRegistrationSelection value) + { + selection = value; + return true; + } + + first.Update(CreateClaim("first", enabled: true), Apply); + second.Update(CreateClaim("second", enabled: true), Apply); + var selected = selection!.Single!.OwnerId == first.OwnerId ? first : second; + var survivor = ReferenceEquals(selected, first) ? second : first; + var survivorLabel = ReferenceEquals(survivor, first) ? "first" : "second"; + + selected.Remove(Apply); + + Assert.AreEqual(survivor.OwnerId, selection.Single!.OwnerId); + Assert.AreEqual(survivorLabel, selection.Single.DisplayName); + Assert.AreEqual(survivorLabel + " single", selection.Single.SingleCommandLine); + Assert.AreEqual(survivor.OwnerId, selection.Pair!.OwnerId); + Assert.AreEqual(survivorLabel + " pair", selection.Pair.ComServerCommandLine); + Assert.IsFalse(selection.MultipleOwners); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + [TestMethod] + public void StaleOwnerAfterAbnormalExitIsRemovedWithoutDiscardingLiveOwner() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var stale = new ExplorerRegistrationCoordinator(store, directory); + using var live = new ExplorerRegistrationCoordinator(store, directory); + stale.Update(CreateClaim("stale", enabled: true), _ => true); + stale.Dispose(); + ExplorerRegistrationSelection? selection = null; + + live.Update(CreateClaim("live", enabled: true), value => + { + selection = value; + return true; + }); + + Assert.AreEqual(live.OwnerId, selection!.Single!.OwnerId); + Assert.AreEqual(live.OwnerId, selection.Pair!.OwnerId); + Assert.AreEqual(1, store.ReadClaims().Count); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + [TestMethod] + public async Task UpdateWaitsForConcurrentOwnersGateRatherThanLosingState() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var owner = new ExplorerRegistrationCoordinator(store, directory); + using var gate = SharedFileLease.Acquire(Path.Combine(directory, "coordination.lock")); + var update = Task.Run(() => owner.Update(CreateClaim("owner", enabled: true), _ => true)); + await Task.Delay(50); + + Assert.IsFalse(update.IsCompleted); + gate.Dispose(); + + Assert.IsTrue(await update); + Assert.AreEqual(owner.OwnerId, store.ReadClaims().Single().OwnerId); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + [TestMethod] + public async Task QuitWaitsForConcurrentOwnersGateAndRestoresSurvivor() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var survivor = new ExplorerRegistrationCoordinator(store, directory); + using var quitting = new ExplorerRegistrationCoordinator(store, directory); + survivor.Update(CreateClaim("survivor", enabled: true), _ => true); + quitting.Update(CreateClaim("quitting", enabled: true), _ => true); + using var gate = SharedFileLease.Acquire(Path.Combine(directory, "coordination.lock")); + ExplorerRegistrationSelection? selection = null; + var removal = Task.Run(() => quitting.Remove(value => + { + selection = value; + return true; + })); + await Task.Delay(50); + + Assert.IsFalse(removal.IsCompleted); + gate.Dispose(); + + Assert.IsTrue(await removal); + Assert.AreEqual(survivor.OwnerId, selection!.Single!.OwnerId); + Assert.AreEqual(survivor.OwnerId, selection.Pair!.OwnerId); + Assert.AreEqual(1, store.ReadClaims().Count); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + [TestMethod] + public void RefreshReconcilesStateAfterBusyGateExceedsDeadline() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var owner = new ExplorerRegistrationCoordinator(store, directory); + using var gate = SharedFileLease.Acquire(Path.Combine(directory, "coordination.lock")); + + Assert.IsFalse(owner.Update(CreateClaim("owner", enabled: true), _ => true)); + Assert.AreEqual(0, store.ReadClaims().Count); + gate.Dispose(); + + Assert.IsTrue(owner.Update(CreateClaim("owner", enabled: true), _ => true)); + Assert.AreEqual(owner.OwnerId, store.ReadClaims().Single().OwnerId); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + [TestMethod] + public void SurvivingOwnerRefreshRemovesQuitClaimWhenCleanupGateTimedOut() + { + var directory = CreateTemporaryDirectory(); + + try + { + var store = new MemoryClaimStore(); + using var survivor = new ExplorerRegistrationCoordinator(store, directory); + using var quitting = new ExplorerRegistrationCoordinator(store, directory); + survivor.Update(CreateClaim("survivor", enabled: true), _ => true); + quitting.Update(CreateClaim("quitting", enabled: true), _ => true); + using var gate = SharedFileLease.Acquire(Path.Combine(directory, "coordination.lock")); + + Assert.IsFalse(quitting.Remove(_ => true)); + gate.Dispose(); + ExplorerRegistrationSelection? selection = null; + + survivor.Update(CreateClaim("survivor", enabled: true), value => + { + selection = value; + return true; + }); + + Assert.AreEqual(survivor.OwnerId, selection!.Single!.OwnerId); + Assert.AreEqual(survivor.OwnerId, selection.Pair!.OwnerId); + Assert.AreEqual(1, store.ReadClaims().Count); + } + finally + { + ExternalDiffWorkspace.TryDelete(directory); + } + } + + private static ExplorerRegistrationClaim CreateClaim(string label, bool enabled) => + new(string.Empty, enabled, label, label + " single", label + ".exe", enabled, label + " pair", label + ".dll"); + + private static string CreateTemporaryDirectory() => + Path.Combine(Path.GetTempPath(), "ClipDiff.Tests", Guid.NewGuid().ToString("N")); + + private sealed class MemoryClaimStore : IExplorerRegistrationClaimStore + { + private readonly Dictionary claims = []; + + public IReadOnlyList ReadClaims() => [.. claims.Values]; + + public void WriteClaim(ExplorerRegistrationClaim claim) => claims[claim.OwnerId] = claim; + + public void DeleteClaim(string ownerId) => claims.Remove(ownerId); + } +} diff --git a/tests/ClipDiff.Windows.Tests/ExternalDiffWorkspaceTests.cs b/tests/ClipDiff.Windows.Tests/ExternalDiffWorkspaceTests.cs index 349de57..6ec84ea 100644 --- a/tests/ClipDiff.Windows.Tests/ExternalDiffWorkspaceTests.cs +++ b/tests/ClipDiff.Windows.Tests/ExternalDiffWorkspaceTests.cs @@ -6,93 +6,144 @@ namespace ClipDiff.Windows.Tests; [TestClass] public sealed class ExternalDiffWorkspaceTests { - [TestMethod] - public void CreatesNamedReadOnlyFilesWithExactUnicodeClipboardText() - { - var root = CreateTemporaryDirectory(); - try - { - var workspace = new ExternalDiffWorkspace(root); - - var files = workspace.Create("previous\r\n秘密\t", "current\n😀"); - - Assert.AreEqual("Previous clipboard.txt", Path.GetFileName(files.PreviousPath)); - Assert.AreEqual("Current clipboard.txt", Path.GetFileName(files.CurrentPath)); - Assert.AreEqual("previous\r\n秘密\t", File.ReadAllText(files.PreviousPath)); - Assert.AreEqual("current\n😀", File.ReadAllText(files.CurrentPath)); - Assert.IsTrue(File.GetAttributes(files.PreviousPath).HasFlag(FileAttributes.ReadOnly)); - Assert.IsTrue(File.GetAttributes(files.CurrentPath).HasFlag(FileAttributes.ReadOnly)); - } - finally - { - ExternalDiffWorkspace.TryDelete(root); - } - } - - [TestMethod] - public void EachComparisonUsesAUniqueDirectory() - { - var root = CreateTemporaryDirectory(); - try - { - var workspace = new ExternalDiffWorkspace(root); - - var first = workspace.Create("one", "two"); - var second = workspace.Create("one", "two"); - - Assert.AreNotEqual(first.DirectoryPath, second.DirectoryPath); - } - finally - { - ExternalDiffWorkspace.TryDelete(root); - } - } - - [TestMethod] - public void FileBackedValuesUseTheirSourceFileNames() - { - var root = CreateTemporaryDirectory(); - try - { - var workspace = new ExternalDiffWorkspace(root); - - var files = workspace.Create("old", "new", "same name.cs", "same name.cs"); - - Assert.AreEqual("same name.cs", Path.GetFileName(files.PreviousPath)); - Assert.AreEqual("same name.cs", Path.GetFileName(files.CurrentPath)); - Assert.AreEqual("Previous", Path.GetFileName(Path.GetDirectoryName(files.PreviousPath))); - Assert.AreEqual("Current", Path.GetFileName(Path.GetDirectoryName(files.CurrentPath))); - } - finally - { - ExternalDiffWorkspace.TryDelete(root); - } - } - - [TestMethod] - public void CleanupRemovesReadOnlyActiveAndStaleComparisonDirectories() - { - var root = CreateTemporaryDirectory(); - try - { - var workspace = new ExternalDiffWorkspace(root); - var files = workspace.Create("secret one", "secret two"); - - workspace.CleanupStaleDirectories(); - - Assert.IsFalse(Directory.Exists(files.DirectoryPath)); - Assert.IsTrue(Directory.Exists(root)); - } - finally - { - ExternalDiffWorkspace.TryDelete(root); - } - } - - private static string CreateTemporaryDirectory() - { - var directory = Path.Combine(Path.GetTempPath(), "ClipDiff.Tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - return directory; - } + [TestMethod] + public void CreatesNamedReadOnlyFilesWithExactUnicodeClipboardText() + { + var root = CreateTemporaryDirectory(); + try + { + var workspace = new ExternalDiffWorkspace(root); + + using var files = workspace.Create("previous\r\n秘密\t", "current\n😀"); + + Assert.AreEqual("Previous clipboard.txt", Path.GetFileName(files.PreviousPath)); + Assert.AreEqual("Current clipboard.txt", Path.GetFileName(files.CurrentPath)); + Assert.AreEqual("previous\r\n秘密\t", File.ReadAllText(files.PreviousPath)); + Assert.AreEqual("current\n😀", File.ReadAllText(files.CurrentPath)); + Assert.IsTrue(File.GetAttributes(files.PreviousPath).HasFlag(FileAttributes.ReadOnly)); + Assert.IsTrue(File.GetAttributes(files.CurrentPath).HasFlag(FileAttributes.ReadOnly)); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + [TestMethod] + public void EachComparisonUsesAUniqueDirectory() + { + var root = CreateTemporaryDirectory(); + try + { + var workspace = new ExternalDiffWorkspace(root); + + using var first = workspace.Create("one", "two"); + using var second = workspace.Create("one", "two"); + + Assert.AreNotEqual(first.DirectoryPath, second.DirectoryPath); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + [TestMethod] + public void FileBackedValuesUseTheirSourceFileNames() + { + var root = CreateTemporaryDirectory(); + try + { + var workspace = new ExternalDiffWorkspace(root); + + using var files = workspace.Create("old", "new", "same name.cs", "same name.cs"); + + Assert.AreEqual("same name.cs", Path.GetFileName(files.PreviousPath)); + Assert.AreEqual("same name.cs", Path.GetFileName(files.CurrentPath)); + Assert.AreEqual("Previous", Path.GetFileName(Path.GetDirectoryName(files.PreviousPath))); + Assert.AreEqual("Current", Path.GetFileName(Path.GetDirectoryName(files.CurrentPath))); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + [TestMethod] + public void CleanupPreservesActiveComparisonFromAnotherOwner() + { + var root = CreateTemporaryDirectory(); + try + { + var workspace = new ExternalDiffWorkspace(root); + using var files = workspace.Create("secret one", "secret two"); + + var otherWorkspace = new ExternalDiffWorkspace(root); + otherWorkspace.CleanupStaleDirectories(); + + Assert.IsTrue(Directory.Exists(files.DirectoryPath)); + Assert.AreEqual("secret one", File.ReadAllText(files.PreviousPath)); + Assert.AreEqual("secret two", File.ReadAllText(files.CurrentPath)); + Assert.IsTrue(Directory.Exists(root)); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + + [TestMethod] + public void CleanupRemovesStaleReadOnlyComparisonDirectories() + { + var root = CreateTemporaryDirectory(); + + try + { + var directory = Path.Combine(root, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "Previous clipboard.txt"); + File.WriteAllText(path, "stale text"); + File.SetAttributes(path, FileAttributes.ReadOnly); + + new ExternalDiffWorkspace(root).CleanupStaleDirectories(); + + Assert.IsFalse(Directory.Exists(directory)); + Assert.IsTrue(Directory.Exists(root)); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + [TestMethod] + public void CleaningOneComparisonDoesNotDeleteAnotherOwnersFiles() + { + var root = CreateTemporaryDirectory(); + + try + { + using var first = new ExternalDiffWorkspace(root).Create("older", "newer"); + var second = new ExternalDiffWorkspace(root).Create("other older", "other newer"); + + second.Dispose(); + new ExternalDiffWorkspace(root).CleanupStaleDirectories(); + + Assert.IsFalse(Directory.Exists(second.DirectoryPath)); + Assert.IsTrue(Directory.Exists(first.DirectoryPath)); + Assert.AreEqual("older", File.ReadAllText(first.PreviousPath)); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + private static string CreateTemporaryDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), "ClipDiff.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } } diff --git a/tests/ClipDiff.Windows.Tests/StartupRegistrationTests.cs b/tests/ClipDiff.Windows.Tests/StartupRegistrationTests.cs index 24cf576..96efa80 100644 --- a/tests/ClipDiff.Windows.Tests/StartupRegistrationTests.cs +++ b/tests/ClipDiff.Windows.Tests/StartupRegistrationTests.cs @@ -33,6 +33,36 @@ public void EnableQuotesExecutableAndDisablePreservesOtherValues() Assert.AreEqual("untouched", key.GetValue("AnotherApp")); } + [TestMethod] + public void HostedStartupCommandIncludesExecutableAndEntryAssembly() + { + var registration = new StartupRegistration( + registryPath, + @"C:\Program Files\dotnet\dotnet.exe", + @"C:\ClipDiff build\日本語\ClipDiff.dll"); + Assert.IsTrue(registration.TrySetEnabled(true)); + + using var key = Registry.CurrentUser.OpenSubKey(registryPath)!; + Assert.AreEqual( + "\"C:\\Program Files\\dotnet\\dotnet.exe\" \"C:\\ClipDiff build\\日本語\\ClipDiff.dll\"", + key.GetValue("ClipDiff")); + } + + [TestMethod] + public void InvalidHostedCommandDoesNotReplaceExistingRegistration() + { + Assert.IsTrue(new StartupRegistration(registryPath, @"C:\Tools\ClipDiff.exe").TrySetEnabled(true)); + + foreach (var path in new[] { "", "ClipDiff.dll", "C:\\bad\"path.dll", "C:\\" + new string('x', 230) + ".dll" }) + { + var registration = new StartupRegistration(registryPath, @"C:\Program Files\dotnet\dotnet.exe", path); + Assert.IsFalse(registration.TrySetEnabled(true)); + } + + using var key = Registry.CurrentUser.OpenSubKey(registryPath)!; + Assert.AreEqual("\"C:\\Tools\\ClipDiff.exe\"", key.GetValue("ClipDiff")); + } + [TestMethod] public void EnablingFromNewLocationReplacesOnlyClipDiffCommand() { From 4466cc020ca34d06e035d93cefee0426698e4bb9 Mon Sep 17 00:00:00 2001 From: stuartd Date: Wed, 7 Oct 2026 22:20:19 +0100 Subject: [PATCH 2/3] Keep hosted command test fixtures valid on Windows --- tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs b/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs index ecd121a..e422c6c 100644 --- a/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs +++ b/tests/ClipDiff.Windows.Tests/ApplicationCommandLineTests.cs @@ -15,7 +15,6 @@ public void ExecutableCommandQuotesSpacesAndUnicode() [TestMethod] [DataRow(@"C:\Program Files\dotnet\dotnet.exe")] [DataRow(@"C:\Program Files\dotnet\DOTNET.EXE")] - [DataRow(@"/usr/local/share/dotnet/dotnet")] public void HostedCommandIncludesQuotedEntryAssembly(string processPath) { var command = ApplicationCommandLine.Build(processPath, @"C:\ClipDiff build\日本語\ClipDiff.dll"); From d735ca7a7e477178c342d918c8149d4761812672 Mon Sep 17 00:00:00 2001 From: stuartd Date: Wed, 7 Oct 2026 22:27:04 +0100 Subject: [PATCH 3/3] Resolve hosted entry paths without single-file reflection --- .../ApplicationCommandLine.cs | 41 +++++++++++++++++-- .../ExplorerContextMenuRegistration.cs | 3 +- .../Startup/StartupRegistration.cs | 3 +- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/ClipDiff.Windows/ApplicationCommandLine.cs b/src/ClipDiff.Windows/ApplicationCommandLine.cs index 2de0928..9ac2c7e 100644 --- a/src/ClipDiff.Windows/ApplicationCommandLine.cs +++ b/src/ClipDiff.Windows/ApplicationCommandLine.cs @@ -4,6 +4,42 @@ namespace ClipDiff.Windows; internal static class ApplicationCommandLine { + private static readonly string? EntryAssemblyPath = ResolveCurrentEntryAssemblyPath(); + + public static string? GetCurrentEntryAssemblyPath() => EntryAssemblyPath; + + private static string? ResolveCurrentEntryAssemblyPath() + { + if (!IsDotnetHost(Environment.ProcessPath)) + { + return null; + } + + var entryPath = Environment.GetCommandLineArgs().FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(entryPath)) + { + return null; + } + + // Resolve once before a file dialog can change the working directory. + // Unlike Assembly.Location, command arguments also support single-file apps. + return Path.GetFullPath(entryPath); + } + + private static bool IsDotnetHost(string? path) + { + if (path is null) + { + return false; + } + + var separatorIndex = Math.Max(path.LastIndexOf('/'), path.LastIndexOf('\\')); + var fileName = path[(separatorIndex + 1)..]; + return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) || + string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase); + } + public static string Build(string processPath, string? entryAssemblyPath) { if (!TryBuild(processPath, entryAssemblyPath, out var command)) @@ -23,10 +59,7 @@ public static bool TryBuild(string? processPath, string? entryAssemblyPath, out return false; } - var separatorIndex = Math.Max(processPath!.LastIndexOf('/'), processPath.LastIndexOf('\\')); - var fileName = processPath[(separatorIndex + 1)..]; - var isDotnetHost = string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) || - string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase); + var isDotnetHost = IsDotnetHost(processPath); if (isDotnetHost && !IsAbsolutePath(entryAssemblyPath)) { diff --git a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs index 0b3fe7a..1e5f3ea 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs @@ -1,6 +1,5 @@ using System.IO; using System.Runtime.InteropServices; -using System.Reflection; using System.Security; using Microsoft.Win32; @@ -50,7 +49,7 @@ public ExplorerContextMenuRegistration() try { - var entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; + var entryAssemblyPath = ApplicationCommandLine.GetCurrentEntryAssemblyPath(); singleCommandLine = ExplorerContextCommandLine.BuildShellCommand(processPath, entryAssemblyPath); comServerCommandLine = ExplorerContextCommandLine.BuildComServerCommand(processPath, entryAssemblyPath); registrationCoordinator = new ExplorerRegistrationCoordinator( diff --git a/src/ClipDiff.Windows/Startup/StartupRegistration.cs b/src/ClipDiff.Windows/Startup/StartupRegistration.cs index 4fcdbbb..22203f9 100644 --- a/src/ClipDiff.Windows/Startup/StartupRegistration.cs +++ b/src/ClipDiff.Windows/Startup/StartupRegistration.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Reflection; using System.Security; using Microsoft.Win32; @@ -35,7 +34,7 @@ public bool TrySetEnabled(bool enabled) if (enabled) { var path = executablePath ?? Environment.ProcessPath; - var assemblyPath = entryAssemblyPath ?? Assembly.GetEntryAssembly()?.Location; + var assemblyPath = entryAssemblyPath ?? ApplicationCommandLine.GetCurrentEntryAssemblyPath(); if (!ApplicationCommandLine.TryBuild(path, assemblyPath, out var command)) {