diff --git a/README.md b/README.md index b3d4485..4ca2603 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ ClipDiff listens to the Windows clipboard directly. No data is ever uploaded. 4. If you copied the newer value normally, press `Ctrl+Alt+D` (or your chosen replacement) or right-click the ClipDiff icon and choose **Show Diff**. The Explorer command opens the diff immediately. 5. You can also copy two files at the same time—or select exactly two files in Explorer and choose **Compare two selected files with ClipDiff**—to diff them immediately without an existing capture. -The built-in viewer is the default. Its reusable window opens in 'Side by Side' mode but can switch to 'Unified', copy the unified difference as ordinary Unicode text, or clear the captured values. Closing the window hides it; **Quit ClipDiff** in the notification-area menu exits the application. +The built-in viewer is the default. Its reusable window opens in 'Side by Side' mode but can switch to 'Unified' or copy the unified difference as ordinary Unicode text. Closing the window hides it; **Quit ClipDiff** in the notification-area menu exits the application. The menu's **Diff viewer** submenu lists supported programs found on the machine, provides **Choose program...** for another executable, and lets you return to the built-in viewer. The selection is remembered. **Keyboard shortcut...** opens a small recorder: click the capture box, press a combination containing Ctrl or Alt (with optional Shift), then choose **Save**. ClipDiff tests the replacement before releasing the current shortcut, reports conflicts in the recorder, and provides **Reset to Ctrl+Alt+D**. Windows-key shortcuts, mouse buttons, `Alt+F4`, and debugger-reserved `F12` are not accepted. Enter saves, Escape cancels, and Tab/Shift-Tab move focus. Releasing an unfinished modifier combination restores the last complete draft. While the capture box has focus, pressing the active global shortcut records it rather than opening a diff. If saving fails, ClipDiff attempts to restore the previous shortcut and explicitly reports when that restoration also fails. -The menu also lets you pause/resume monitoring, shows short previews of the current and previous entries, and provides an **About ClipDiff** window with project links. A file-backed entry always includes its filename before the preview. Resuming starts from the clipboard's then-current sequence and does not import text copied while paused. If another application owns the configured shortcut, ClipDiff continues to work through its notification-area menu and labels the command **Show Diff (shortcut unavailable)**. +The menu also shows short previews of the current and previous entries, and provides an **About ClipDiff** window with project links. A file-backed entry always includes its filename before the preview. Monitoring stays active while ClipDiff is running; use **Quit ClipDiff** to stop it and discard captured text. If another application owns the configured shortcut, ClipDiff continues to work through its notification-area menu and labels the command **Show Diff (shortcut unavailable)**. ## Copied files @@ -38,9 +38,9 @@ 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. Clearing removes only the individual-file command, while pausing or 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. A later ClipDiff start cleans up registrations left by an abnormal exit. -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 while monitoring is paused or ClipDiff has exited. +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. Copies containing more than two files are ignored; ClipDiff never creates a comparison value from a file list. @@ -67,13 +67,13 @@ ClipDiff checks Windows App Paths, `PATH`, and the programs' usual install locat ## Privacy model and limitations -ClipDiff captures only future, non-empty text values (including the copied-file conversion above) and retains at most two accepted values in process memory. Consecutive copies of identical text are accepted as separate entries, allowing ClipDiff to report **No differences**. Unsupported non-text clipboard changes do not alter the entries. **Clear Captured Text** removes the in-memory entries and active diff without changing the Windows clipboard. ClipDiff never logs, uploads, or transmits captured text or file contents. +ClipDiff captures only future, non-empty text values (including the copied-file conversion above) and retains at most two accepted values in process memory. Consecutive copies of identical text are accepted as separate entries, allowing ClipDiff to report **No differences**. Unsupported non-text clipboard changes do not alter the entries. Quitting discards the in-memory entries and active diff without changing the Windows clipboard. ClipDiff never logs, uploads, or transmits captured text or file contents. 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. -ClipDiff stores only the selected executable path, the one-time-warning acknowledgement, and the chosen shortcut's modifier/key codes 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 every temporary comparison directory. Before reading text, ClipDiff inspects and honours these advisory [clipboard formats](https://learn.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats): @@ -272,12 +272,14 @@ On a Windows desktop, verify: - two future text copies, including identical text, or one exact-two-file copy produce **Ready to diff** and `Ctrl+Alt+D` opens the window; - side-by-side rows remain aligned, unified output is exact, and long lines wrap; - copied and directly selected files show their source filename in the tray item and on the corresponding diff side, including unified output and external viewers; equal filenames from different paths use the shortest unique suffix; +- the view dropdown is readable closed and open, including hovered/selected items; Tab, arrow keys, Enter, Escape, and Alt+Down still operate it; +- 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; - 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; -- `%LOCALAPPDATA%\ClipDiff\settings.json` contains only the executable preference, warning acknowledgement, and shortcut codes, never clipboard content; +- `%LOCALAPPDATA%\ClipDiff\settings.json` contains only the executable preference, warning acknowledgement, shortcut codes, and the login-prompt flag, never clipboard content; - **Keyboard shortcut...** records a replacement such as `Ctrl+Alt+6`, keeps the old shortcut active when the replacement is already in use, updates the tray label after success, persists across restart, and resets to `Ctrl+Alt+D`; - **Copy unified diff** pastes into Notepad, has all three exclusion formats, and is not recaptured; - two identical copies produce a diff reporting **No differences**, while images leave history unchanged; @@ -285,10 +287,8 @@ On a Windows desktop, verify: - exactly two copied files—or two files copied with Explorer **Copy as path**—become the previous/current comparison pair in clipboard order, while copies of more than two files are ignored; missing, unreadable, empty, directory, oversized, and binary entries fall back to a filename with the reason appended; - after one capture, right-clicking a second file offers **Compare with current ClipDiff capture** (including the current filename when file-backed), makes that file the new current entry, and immediately opens the selected diff viewer without changing the clipboard; - selecting exactly two Explorer files offers **Compare two selected files with ClipDiff** without requiring a prior capture, makes the first Shell-supplied path previous and the second current, and immediately opens the selected viewer without changing the clipboard; -- selecting one file, three or more files, folders, or a mixture of files and folders hides the two-file command; invalid invocations do not alter history or cause file reads; clearing removes the individual-file command but leaves the two-file command available for exactly two files; -- pausing and quitting remove both Explorer commands, and Windows 11 exposes classic commands under **Show more options** when they are not in the primary menu; -- pausing skips copied text and resuming establishes a new baseline; -- clearing captured text does not modify the Windows clipboard; +- selecting one file, three or more files, folders, or a mixture of files and folders hides the two-file command; invalid invocations do not alter history or cause file reads; +- quitting removes both Explorer commands, and Windows 11 exposes classic commands under **Show more options** when they are not in the primary menu; - closing the diff window leaves the tray application running; - occupying `Ctrl+Alt+D` leaves **Show Diff** usable from the menu; - a privacy-marked item is not read, and a recent unmarked value followed by a clear is removed; @@ -298,4 +298,28 @@ Run the same checks through Remote Desktop if Windows Server 2022 is the intende ## Optional start at sign-in -ClipDiff cannot recover values copied before it starts because it does not persist clipboard content. If desired, create a shortcut to `ClipDiff.exe` in the folder opened by `shell:startup`. +ClipDiff asks once on its first normal launch whether it should start automatically +when you sign in to Windows. Both Yes and No dismiss future prompts. Existing users +see the question once after upgrading. Change the choice at any time using +**Start at login** in the notification-area context menu. + +Enabling this registers the quoted executable path in the current user's +`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` key; no administrator +access is required. The check mark reflects that registration. Windows Startup +Apps/Task Manager can separately disable it; re-enable it there if necessary. +Disabling the menu option removes ClipDiff's Run value, and quitting the app +leaves the registration intact. + +After extracting an update to a new directory, run the new executable once: +an existing registration is updated to that location. Remove any shortcut you +previously added to `shell:startup`; the toggle does not manage manual shortcuts. + +ClipDiff still captures only values copied after it starts. + +Windows verification: +- Answer No, restart, and confirm the question does not return. +- Enable **Start at login**, sign out/in, and confirm one tray instance starts. +- Disable it, sign out/in, and confirm ClipDiff does not start. +- Answer Yes on a fresh profile and verify the same login behaviour. +- Launch an update from a path containing spaces and confirm login uses it. +- Check that registry/settings write failures produce a warning and no false check mark. diff --git a/SPEC.md b/SPEC.md index 6fbada7..3f8acb4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -108,7 +108,7 @@ The application must not: - Add accounts or cloud services. - Add a clipboard-history browser. - Retain more than the two values needed for the comparison. -- Add settings or onboarding beyond the external-viewer executable path, warning acknowledgement, and global keyboard shortcut, or add a complex preferences interface. +- Add settings or onboarding beyond the external-viewer executable path, warning acknowledgement, global keyboard shortcut, and start-at-login prompt, or add a complex preferences interface. - Add document-management features or file access beyond the copied-file conversion and explicit Explorer actions in section 6.4. - Use a terminal window or embedded web UI for the diff. - Require an installer for the initial personal release. @@ -147,7 +147,7 @@ When ClipDiff starts: This avoids unexpectedly importing a value that predates application startup. -Consequently, the user must copy two values after ClipDiff starts. Running ClipDiff at sign-in can be documented as an optional setup step, but no startup-settings UI is required. +Consequently, the user must copy two values after ClipDiff starts. Offer the one-time start-at-login prompt and tray toggle described in section 24. ### 6.2 Accepted text @@ -215,33 +215,18 @@ If the clipboard is explicitly empty, or contains an empty Unicode text value: - Apply the recent-clear privacy heuristic if appropriate. - Otherwise leave existing history unchanged. -### 6.6 Monitoring pause +### 6.6 Continuous monitoring -The notification-area menu must contain a checked **Monitor Clipboard** item. +Monitoring stays active for the application lifetime. Do not expose a monitoring +pause toggle in the notification-area menu. Quit ClipDiff stops monitoring. +Internal pause/resume policy and its tests may remain for native lifecycle use. -When monitoring is disabled: +### 6.7 Captured text lifetime -- Leave existing entries untouched. -- Ignore incoming clipboard changes. -- Show status **Monitoring paused**. - -When monitoring is re-enabled: - -- Record the clipboard’s current sequence number. -- Do not capture values copied while monitoring was paused. -- Resume from the next clipboard change. - -### 6.7 Clear Captured Text - -The **Clear Captured Text** command must: - -- Remove both in-memory entries. -- Remove the active diff document. -- Return the status to **Waiting for copied text**. -- Not clear or alter the Windows clipboard. -- Not close the application. - -Disable the command when there are no entries. +Do not expose a Clear Captured Text command in either the tray or built-in viewer. +Quitting discards the two-entry history and active diff without changing the +Windows clipboard. Keep automatic recent-clear privacy handling and shutdown +cleanup intact. ## 7. Sensitive clipboard content @@ -768,8 +753,7 @@ Previous: [disabled] Show Diff (Ctrl+Alt+D) Diff viewer: Built-in [submenu] Keyboard shortcut... -Monitor Clipboard [checked/unchecked] -Clear Captured Text +Start at login [checked/unchecked] ────────────────────────────────── About ClipDiff Quit ClipDiff @@ -780,8 +764,7 @@ Behaviour: - **Show Diff** is disabled until two entries are available. - **Diff viewer** selects the built-in viewer, a detected external program, or an executable chosen by the user. - **Keyboard shortcut...** opens the shortcut recorder without changing the active shortcut unless Save succeeds. -- **Monitor Clipboard** toggles monitoring. -- **Clear Captured Text** is disabled when history is empty. +- **Start at login** toggles per-user startup registration (section 24). - **About ClipDiff** opens a reusable native window showing the application icon, the product version and short source commit hash, Stuart Dunkeld, `stuartd.dev`, and the source repository link. - **Quit ClipDiff** removes the tray icon, unregisters native listeners/hotkeys, clears references to captured text, and shuts down. - Double-clicking the tray icon may invoke **Show Diff** when available. @@ -837,11 +820,10 @@ The top header contains: - Title: `Clipboard Diff` - Summary label -- View selector: +- View selector (explicit dark templates keep the selected value and popup items readable in all Windows themes; preserve native keyboard selection and visible focus): - `Side by Side` - `Unified` - **Copy unified diff** button -- **Clear captured text** button The view defaults to **Side by Side** each application launch. It may remain at the user’s selected mode during the current process lifetime. It does not need to be persisted. @@ -921,7 +903,7 @@ After acknowledgement, create a unique per-comparison directory below `%LOCALAPP 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. -Persist application preferences in `%LOCALAPPDATA%\ClipDiff\settings.json`. The file may contain only the selected executable path, the one-time-warning acknowledgement, and the global-shortcut modifier/virtual-key values. 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. +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. ## 17. Suggested solution structure @@ -1276,8 +1258,6 @@ On Windows: 1. Inspect the clipboard formats and verify exclusion markers. 1. Copy identical text twice and confirm Show Diff reports No differences. 1. Copy an image and confirm history does not change. -1. Pause monitoring, copy text, resume, and confirm paused text was not captured. -1. Clear captured text and confirm the Windows clipboard itself remains unchanged. 1. Close the diff window and confirm ClipDiff remains in the tray. 1. Quit ClipDiff and restart it; confirm no previous text returns. 1. Run a second instance and confirm it exits cleanly. @@ -1296,11 +1276,11 @@ On Windows: 1. Confirm the per-comparison directory and its two read-only files are removed after the launched process exits and when ClipDiff exits. 1. Leave a simulated stale comparison directory and confirm the next ClipDiff start removes it. 1. Remove or rename the selected executable and confirm Show Diff falls back to the built-in viewer. -1. Inspect settings.json and confirm it contains only the selected path, acknowledgement, and shortcut codes, never captured text or previews. +1. Inspect settings.json and confirm it contains only the selected path, acknowledgement, shortcut codes, and startup-prompt flag, never captured text or previews. 1. Copy one file, then right-click a second file and choose **Compare with current ClipDiff capture**; confirm the second file becomes current, the copied file becomes previous, and the configured viewer opens immediately without changing the clipboard. -1. Confirm the Explorer command is single-file only, uses the same binary/encoding/fallback rules, disappears after pause, clear, and quit, and is under **Show more options** on Windows 11 when not shown in the primary menu. +1. Confirm the Explorer command is single-file only, uses the same binary/encoding/fallback rules, disappears on quit or when no capture remains, and is under **Show more options** on Windows 11 when not shown in the primary menu. 1. With no captured value required, select exactly two files in Explorer and choose **Compare two selected files with ClipDiff**; confirm the first Explorer-supplied path becomes previous, the second becomes current, and the configured viewer opens immediately without changing the clipboard. -1. Confirm the two-file command uses the same binary/encoding/fallback rules, is hidden for one file, three or more files, folders, and mixed file/folder selections, and rejects invalid invocations without reading files. Confirm it remains available for exactly two files after clearing captured text, disappears while monitoring is paused and on quit, reappears for exactly two files after resuming, and is under **Show more options** on Windows 11 when not shown in the primary menu. +1. Confirm the two-file command uses the same binary/encoding/fallback rules, is hidden for one file, three or more files, folders, and mixed file/folder selections, and rejects invalid invocations without reading files. Confirm it disappears on quit and is under **Show more options** on Windows 11 when not shown in the primary menu. 1. Upgrade from the old static verb, including a simulated abnormal exit, and confirm there is only one two-file command and no obsolete `CommandStateHandler` registration. Remove the native DLL before starting ClipDiff and confirm clipboard/tray workflows remain usable with no broken two-file menu. Restore the DLL and restart before testing the Explorer command again. 1. Compare two files with the same basename from different directories and confirm the tray, built-in views, copied diff, and supported external-viewer titles use the shortest unique path suffixes. 1. Start ClipDiff after a simulated abnormal exit and confirm it removes an owned stale Explorer registration when there is no captured entry. @@ -1394,17 +1374,31 @@ dotnet build --configuration Release The native clipboard, hotkey, tray, and WPF behaviour still require manual Windows testing. -## 24. Optional start-at-sign-in documentation +## 24. Start at login -ClipDiff cannot recover clipboard values copied before it started because clipboard content is not persisted. +On the first normal launch, ask whether ClipDiff should start when the user signs +in to Windows. Remember either answer in `StartupPromptShown` in settings.json. +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. -The README may explain how the user can place a shortcut in: +Provide a checked **Start at login** tray-menu item. Store only the quoted +executable path under the current user's Run key, using the value name ClipDiff. +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. +Report settings-save failures so users know the question may recur. -``` -shell:startup -``` +On normal launch, update an existing registration to the current executable +location, supporting portable updates. Never recreate a missing registration +merely because the prompt was answered before. Quitting must retain registration. +Windows Startup Apps/Task Manager can independently disable the Run entry; do +not override Windows' approval state. The check mark indicates registration. +Manual Startup-folder shortcuts remain user-managed and must be removed when +switching to the built-in option to avoid launching an older copy. -Do not add a **Start with Windows** settings screen in the first version. +Verify opt-in/out, prompt persistence, upgrade path quoting, failures, and actual +sign-out/sign-in behaviour on Windows. ## 25. README content @@ -1454,7 +1448,6 @@ The first release is complete when all of these are true: - Summary counts are correct. - **Copy unified diff** produces the agreed output. - Copied diff output is excluded from history/cloud processing and is not recaptured. -- **Clear Captured Text** resets state without changing the Windows clipboard. - Closing the diff window leaves the tray app running. - Quitting releases native resources and loses all captured content. - Core unit tests pass. diff --git a/src/ClipDiff.Windows/App.xaml.cs b/src/ClipDiff.Windows/App.xaml.cs index b7db522..5dd7e20 100644 --- a/src/ClipDiff.Windows/App.xaml.cs +++ b/src/ClipDiff.Windows/App.xaml.cs @@ -37,6 +37,10 @@ protected override void OnStartup(StartupEventArgs args) { controller.CompareWithCurrent(selectedFilePath); } + else + { + Dispatcher.InvokeAsync(controller.InitializeStartAtLogin); + } } protected override void OnExit(ExitEventArgs args) diff --git a/src/ClipDiff.Windows/AppController.cs b/src/ClipDiff.Windows/AppController.cs index 2200afb..f793ae8 100644 --- a/src/ClipDiff.Windows/AppController.cs +++ b/src/ClipDiff.Windows/AppController.cs @@ -7,6 +7,7 @@ using ClipDiff.Windows.Hotkeys; using ClipDiff.Windows.Native; using ClipDiff.Windows.Settings; +using ClipDiff.Windows.Startup; using ClipDiff.Windows.Tray; using ClipDiff.Windows.ViewModels; using ClipDiff.Windows.Views; @@ -37,6 +38,7 @@ internal sealed class AppController : IDisposable private DiffWindow? diffWindow; private AboutWindow? aboutWindow; private ShortcutWindow? shortcutWindow; + private readonly StartupRegistration startupRegistration = new(); private bool disposed; public AppController() @@ -53,7 +55,7 @@ public AppController() trayIcon = new TrayIconController( externalDiffTools, GetSelectedExternalDiffTool()?.ExecutablePath); - viewModel = new DiffWindowViewModel(CopyDiff, ClearCapturedText, Recompare); + viewModel = new DiffWindowViewModel(CopyDiff, Recompare); explorerCommandServer = new ExplorerCommandServer(CompareWithSelectedFileAsync); explorerDropTargetServer = new ExplorerDropTargetServer( OnExplorerFilesSelected, @@ -64,15 +66,94 @@ public AppController() hotKey.Pressed += OnHotKeyPressed; trayIcon.ShowDiffRequested += OnShowDiffRequested; trayIcon.ShortcutRequested += OnShortcutRequested; - trayIcon.ToggleMonitoringRequested += OnToggleMonitoringRequested; trayIcon.DiffToolSelected += OnDiffToolSelected; trayIcon.ChooseDiffToolRequested += OnChooseDiffToolRequested; - trayIcon.ClearRequested += OnClearRequested; trayIcon.AboutRequested += OnAboutRequested; trayIcon.QuitRequested += OnQuitRequested; + trayIcon.ToggleStartAtLoginRequested += OnToggleStartAtLoginRequested; + trayIcon.MenuOpening += OnTrayMenuOpening; UpdatePresentation(); } + public void InitializeStartAtLogin() + { + if (disposed) + { + return; + } + + // Follow a portable upgrade to a new directory only when already opted in. + if (startupRegistration.TryRead(out var registered) && registered) + { + if (!startupRegistration.TrySetEnabled(true)) + { + ShowStartupError("ClipDiff could not update its login registration to this location."); + } + + RememberStartupPrompt(); + } + else if (!settings.StartupPromptShown) + { + var answer = System.Windows.MessageBox.Show( + "Start ClipDiff automatically when you sign in to Windows?\n\n" + + "You can change this later using Start at login in the tray menu.", + "Start ClipDiff at login", + MessageBoxButton.YesNo, + MessageBoxImage.Question, + MessageBoxResult.No); + + if (answer == MessageBoxResult.Yes && !startupRegistration.TrySetEnabled(true)) + { + ShowStartupError("ClipDiff could not enable start at login. You can try again from the tray menu."); + } + + RememberStartupPrompt(); + } + + RefreshStartAtLogin(); + } + + private void RememberStartupPrompt() + { + if (settings.StartupPromptShown) + { + return; + } + + settings = settings with { StartupPromptShown = true }; + + if (!settingsStore.TrySave(settings)) + { + ShowStartupError("ClipDiff could not save your answer. The startup question may appear next time."); + } + } + + private void OnTrayMenuOpening(object? sender, EventArgs args) => RefreshStartAtLogin(); + + private void RefreshStartAtLogin() + { + var available = startupRegistration.TryRead(out var registered); + trayIcon.SetStartAtLogin(registered, available); + } + + private void OnToggleStartAtLoginRequested(object? sender, EventArgs args) + { + if (!startupRegistration.TryRead(out var registered) || + !startupRegistration.TrySetEnabled(!registered)) + { + ShowStartupError("ClipDiff could not change start at login."); + } + else + { + RememberStartupPrompt(); + } + + RefreshStartAtLogin(); + } + + private static void ShowStartupError(string message) => + System.Windows.MessageBox.Show(message, "ClipDiff", MessageBoxButton.OK, MessageBoxImage.Warning); + public void CompareWithCurrent(string selectedFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(selectedFilePath); @@ -97,12 +178,12 @@ public void Dispose() hotKey.Pressed -= OnHotKeyPressed; trayIcon.ShowDiffRequested -= OnShowDiffRequested; trayIcon.ShortcutRequested -= OnShortcutRequested; - trayIcon.ToggleMonitoringRequested -= OnToggleMonitoringRequested; trayIcon.DiffToolSelected -= OnDiffToolSelected; trayIcon.ChooseDiffToolRequested -= OnChooseDiffToolRequested; - trayIcon.ClearRequested -= OnClearRequested; trayIcon.AboutRequested -= OnAboutRequested; trayIcon.QuitRequested -= OnQuitRequested; + trayIcon.ToggleStartAtLoginRequested -= OnToggleStartAtLoginRequested; + trayIcon.MenuOpening -= OnTrayMenuOpening; explorerContextMenuRegistration.Dispose(); explorerDropTargetServer.Dispose(); @@ -161,23 +242,6 @@ private void OnHotKeyPressed(object? sender, EventArgs args) private void OnShowDiffRequested(object? sender, EventArgs args) => ShowDiff(); - private void OnToggleMonitoringRequested(object? sender, EventArgs args) - { - if (history.IsMonitoring) - { - clipboardMonitor.Pause(); - history.Pause(); - } - else - { - history.Resume(clipboardMonitor.Resume()); - } - - UpdatePresentation(); - } - - private void OnClearRequested(object? sender, EventArgs args) => ClearCapturedText(); - private void OnAboutRequested(object? sender, EventArgs args) { aboutWindow ??= new AboutWindow(); @@ -541,14 +605,6 @@ private void CopyDiff() UpdatePresentation(); } - private void ClearCapturedText() - { - ClearComparison(); - history.Clear(); - viewModel.ClearDocument(); - UpdatePresentation(); - } - private void UpdatePresentation() { var status = clipboardMonitor.IsRegistered || !history.IsMonitoring @@ -558,10 +614,8 @@ private void UpdatePresentation() status, hotKey.IsRegistered, hotKey.Gesture.DisplayText, - history.IsMonitoring, history.Current, history.Previous); - viewModel.SetCanClear(history.Current is not null); explorerContextMenuRegistration.SetState( history.IsMonitoring, history.Current is not null, diff --git a/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs b/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs index 6ab613d..c6a66bf 100644 --- a/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs +++ b/src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs @@ -7,7 +7,8 @@ namespace ClipDiff.Windows.Settings; internal sealed record ClipDiffSettings( string? SelectedExecutablePath = null, bool PlaintextWarningAcknowledged = false, - HotKeyGesture? HotKey = null); + HotKeyGesture? HotKey = null, + bool StartupPromptShown = false); internal sealed class ClipDiffSettingsStore { diff --git a/src/ClipDiff.Windows/Startup/StartupRegistration.cs b/src/ClipDiff.Windows/Startup/StartupRegistration.cs new file mode 100644 index 0000000..679ba7c --- /dev/null +++ b/src/ClipDiff.Windows/Startup/StartupRegistration.cs @@ -0,0 +1,66 @@ +using System.IO; +using System.Security; +using Microsoft.Win32; + +namespace ClipDiff.Windows.Startup; + +internal sealed class StartupRegistration( + string registryPath = @"Software\Microsoft\Windows\CurrentVersion\Run", + string? executablePath = null) +{ + private const string ValueName = "ClipDiff"; + + public bool TryRead(out bool registered) + { + registered = false; + + try + { + using var key = Registry.CurrentUser.OpenSubKey(registryPath); + registered = key?.GetValue(ValueName) is string command && !string.IsNullOrWhiteSpace(command); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or SecurityException) + { + return false; + } + } + + public bool TrySetEnabled(bool enabled) + { + try + { + if (enabled) + { + var path = executablePath ?? Environment.ProcessPath; + + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.Contains('"')) + { + return false; + } + + var command = $"\"{path}\""; + + // Windows limits Run value command lines to 260 characters. + if (command.Length > 260) + { + return false; + } + + using var key = Registry.CurrentUser.CreateSubKey(registryPath); + key.SetValue(ValueName, command, RegistryValueKind.String); + } + else + { + using var key = Registry.CurrentUser.OpenSubKey(registryPath, writable: true); + key?.DeleteValue(ValueName, throwOnMissingValue: false); + } + + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or SecurityException) + { + return false; + } + } +} diff --git a/src/ClipDiff.Windows/Tray/TrayIconController.cs b/src/ClipDiff.Windows/Tray/TrayIconController.cs index 9e0b6c2..be4951c 100644 --- a/src/ClipDiff.Windows/Tray/TrayIconController.cs +++ b/src/ClipDiff.Windows/Tray/TrayIconController.cs @@ -15,8 +15,7 @@ internal sealed class TrayIconController : IDisposable private readonly Forms.ToolStripMenuItem showDiffItem; private readonly Forms.ToolStripMenuItem diffViewerItem; private readonly Forms.ToolStripMenuItem shortcutItem; - private readonly Forms.ToolStripMenuItem monitorItem; - private readonly Forms.ToolStripMenuItem clearItem; + private readonly Forms.ToolStripMenuItem startAtLoginItem; private bool disposed; private IReadOnlyList diffTools = []; @@ -32,15 +31,13 @@ public TrayIconController( showDiffItem = new Forms.ToolStripMenuItem("Show Diff (Ctrl+Alt+D)"); diffViewerItem = new Forms.ToolStripMenuItem("Diff viewer"); shortcutItem = new Forms.ToolStripMenuItem("Keyboard shortcut..."); - monitorItem = new Forms.ToolStripMenuItem("Monitor Clipboard") { CheckOnClick = false }; - clearItem = new Forms.ToolStripMenuItem("Clear Captured Text"); + startAtLoginItem = new Forms.ToolStripMenuItem("Start at login") { CheckOnClick = false }; var aboutItem = new Forms.ToolStripMenuItem("About ClipDiff"); var quitItem = new Forms.ToolStripMenuItem("Quit ClipDiff"); showDiffItem.Click += (_, _) => ShowDiffRequested?.Invoke(this, EventArgs.Empty); shortcutItem.Click += (_, _) => ShortcutRequested?.Invoke(this, EventArgs.Empty); - monitorItem.Click += (_, _) => ToggleMonitoringRequested?.Invoke(this, EventArgs.Empty); - clearItem.Click += (_, _) => ClearRequested?.Invoke(this, EventArgs.Empty); + startAtLoginItem.Click += (_, _) => ToggleStartAtLoginRequested?.Invoke(this, EventArgs.Empty); aboutItem.Click += (_, _) => AboutRequested?.Invoke(this, EventArgs.Empty); quitItem.Click += (_, _) => QuitRequested?.Invoke(this, EventArgs.Empty); SetDiffTools(diffTools, selectedDiffExecutablePath); @@ -55,12 +52,12 @@ public TrayIconController( showDiffItem, diffViewerItem, shortcutItem, - monitorItem, - clearItem, + startAtLoginItem, new Forms.ToolStripSeparator(), aboutItem, quitItem ]); + menu.Opening += (_, _) => MenuOpening?.Invoke(this, EventArgs.Empty); DarkMenuRenderer.ApplyTo(menu); applicationIcon = TryLoadApplicationIcon(); @@ -74,9 +71,18 @@ public TrayIconController( notifyIcon.DoubleClick += OnDoubleClick; } - public event EventHandler? ShowDiffRequested; + public event EventHandler? ToggleStartAtLoginRequested; + + public event EventHandler? MenuOpening; - public event EventHandler? ToggleMonitoringRequested; + public void SetStartAtLogin(bool registered, bool available) + { + startAtLoginItem.Checked = registered; + startAtLoginItem.Enabled = available; + startAtLoginItem.Text = available ? "Start at login" : "Start at login (unavailable)"; + } + + public event EventHandler? ShowDiffRequested; public event EventHandler? ShortcutRequested; @@ -84,8 +90,6 @@ public TrayIconController( public event EventHandler? ChooseDiffToolRequested; - public event EventHandler? ClearRequested; - public event EventHandler? AboutRequested; public event EventHandler? QuitRequested; @@ -103,7 +107,6 @@ public void Update( string status, bool hotKeyAvailable, string hotKeyDisplayText, - bool monitoring, ClipboardEntry? current, ClipboardEntry? previous) { @@ -115,8 +118,6 @@ public void Update( currentItem.Text = "Current: " + EntryPreview(current, fileLabels.Current); previousItem.Text = "Previous: " + EntryPreview(previous, fileLabels.Previous); showDiffItem.Enabled = current is not null && previous is not null; - monitorItem.Checked = monitoring; - clearItem.Enabled = current is not null; } public void Dispose() diff --git a/src/ClipDiff.Windows/ViewModels/DiffWindowViewModel.cs b/src/ClipDiff.Windows/ViewModels/DiffWindowViewModel.cs index 3f79d89..0cd45db 100644 --- a/src/ClipDiff.Windows/ViewModels/DiffWindowViewModel.cs +++ b/src/ClipDiff.Windows/ViewModels/DiffWindowViewModel.cs @@ -7,18 +7,15 @@ namespace ClipDiff.Windows.ViewModels; internal sealed class DiffWindowViewModel : INotifyPropertyChanged { private readonly RelayCommand copyCommand; - private readonly RelayCommand clearCommand; private DiffDocument? document; private int selectedViewIndex; - private bool canClear; private bool ignoreSpacing; private readonly Action comparisonOptionsChanged; - public DiffWindowViewModel(Action copy, Action clear, Action comparisonOptionsChanged) + public DiffWindowViewModel(Action copy, Action comparisonOptionsChanged) { this.comparisonOptionsChanged = comparisonOptionsChanged; copyCommand = new RelayCommand(copy, () => document is not null); - clearCommand = new RelayCommand(clear, () => canClear); } public event PropertyChangedEventHandler? PropertyChanged; @@ -86,8 +83,6 @@ public bool IgnoreSpacing public RelayCommand CopyCommand => copyCommand; - public RelayCommand ClearCommand => clearCommand; - public void Load(DiffDocument diffDocument) { this.document = diffDocument ?? throw new ArgumentNullException(nameof(diffDocument)); @@ -100,17 +95,6 @@ public void ClearDocument() RaiseDocumentProperties(); } - public void SetCanClear(bool canClearValue) - { - if (this.canClear == canClearValue) - { - return; - } - - this.canClear = canClearValue; - clearCommand.RaiseCanExecuteChanged(); - } - private static IReadOnlyList CreateUnifiedLines(DiffDocument document) { var lines = new List diff --git a/src/ClipDiff.Windows/Views/DiffWindow.xaml b/src/ClipDiff.Windows/Views/DiffWindow.xaml index 1290247..e6b6b1c 100644 --- a/src/ClipDiff.Windows/Views/DiffWindow.xaml +++ b/src/ClipDiff.Windows/Views/DiffWindow.xaml @@ -33,16 +33,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -125,16 +187,16 @@ - - -