diff --git a/src/ClipPort/MainWindow.Settings.cs b/src/ClipPort/MainWindow.Settings.cs index a0937d0..09740b3 100644 --- a/src/ClipPort/MainWindow.Settings.cs +++ b/src/ClipPort/MainWindow.Settings.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.ComponentModel; +using System.Diagnostics; namespace ClipPort; @@ -36,6 +37,50 @@ private async void SettingsPage_SettingsChanged(object? sender, EventArgs e) AppLanguage requestedLanguage = requestedSettings.Language; AppLanguage previousLanguage = _previousLanguage; bool languageChanged = requestedLanguage != previousLanguage; + long? explorerOperationId = null; + + if (languageChanged) + { + // Commit the pending language before any await so a concurrent + // settings change does not re-enter this path and roll back + // unrelated values; the old language is restored if saving fails. + _previousLanguage = requestedLanguage; + } + + if (languageChanged && _appSettings.ExplorerContextMenuEnabled) + { + bool shouldDeferExplorerUpdate; + try + { + shouldDeferExplorerUpdate = _explorerContextMenuService + .ShouldDeferExplorerSynchronization(); + } + catch (Exception ex) when ( + ex is UnauthorizedAccessException or IOException or InvalidOperationException or + System.Runtime.InteropServices.COMException or ArgumentException or + NotSupportedException) + { + ApplySettingsSnapshot(_lastSavedSettings); + _previousLanguage = previousLanguage; + SettingsPage.Initialize(_appSettings); + LogText.Text = ResourceService.Format( + "Format.SettingsSaveFailed", + ex.Message); + return; + } + + if (!shouldDeferExplorerUpdate) + { + if (SettingsPage.TryBeginExplorerIntegrationOperation( + out long operationId)) + { + explorerOperationId = operationId; + } + // When another Explorer operation is active, the language + // change is still persisted and its shared-menu update is + // deferred instead of discarding this settings change. + } + } _historyService.SetReportsDirectory(_appSettings.LogAndReportDirectory); _logService.SetDirectory(_appSettings.LogAndReportDirectory); @@ -50,25 +95,33 @@ private async void SettingsPage_SettingsChanged(object? sender, EventArgs e) // A failed save must not leave the visible settings ahead of // what will actually be restored on the next launch. ApplySettingsSnapshot(_lastSavedSettings); + _previousLanguage = previousLanguage; _historyService.SetReportsDirectory(_appSettings.LogAndReportDirectory); _logService.SetDirectory(_appSettings.LogAndReportDirectory); SettingsPage.Initialize(_appSettings); ApplyTheme(); LogText.Text = ResourceService.Format("Format.SettingsSaveFailed", ex.Message); + if (explorerOperationId is long operationId) + { + ApplyExplorerContextMenuStatus( + _explorerContextMenuService.GetStatus(), + completingOperationId: operationId); + } return; } _lastSavedSettings = requestedSettings; if (languageChanged) { - _previousLanguage = requestedLanguage; - if (_appSettings.ExplorerContextMenuEnabled) + if (explorerOperationId is long operationId) { ExplorerContextMenuStatus contextMenuStatus = await _explorerContextMenuService.SetEnabledAsync( true, requestedLanguage); - ApplyExplorerContextMenuStatus(contextMenuStatus); + ApplyExplorerContextMenuStatus( + contextMenuStatus, + completingOperationId: operationId); } await ShowLanguageRestartDialogAsync(); } @@ -103,7 +156,9 @@ await _explorerContextMenuService.SetEnabledAsync( _appSettings.Language); if (status.ErrorMessage is not null || status.IsEnabled != e.Enabled) { - ApplyExplorerContextMenuStatus(status with { IsEnabled = previousEnabled }); + ApplyExplorerContextMenuStatus( + status with { IsEnabled = previousEnabled }, + completingOperationId: e.OperationId); return; } @@ -112,7 +167,9 @@ await _explorerContextMenuService.SetEnabledAsync( { await App.SettingsService.SaveAsync(_appSettings); _lastSavedSettings = CloneSettings(_appSettings); - ApplyExplorerContextMenuStatus(status); + ApplyExplorerContextMenuStatus( + status, + completingOperationId: e.OperationId); } catch (Exception ex) when ( ex is IOException or UnauthorizedAccessException) @@ -122,33 +179,50 @@ await _explorerContextMenuService.SetEnabledAsync( await _explorerContextMenuService.SetEnabledAsync( previousEnabled, _appSettings.Language); - ApplyExplorerContextMenuStatus(rollbackStatus with - { - ErrorMessage = ex.Message - }); + ApplyExplorerContextMenuStatus( + rollbackStatus with + { + ErrorMessage = ex.Message + }, + completingOperationId: e.OperationId); } } private async Task SynchronizeExplorerContextMenuAsync() { + if (!SettingsPage.TryBeginExplorerIntegrationOperation( + out long operationId)) + { + // A user-started maintenance action already owns the same package + // and certificate state, so startup synchronization must not race it. + return; + } + ExplorerContextMenuStatus status = await _explorerContextMenuService.SynchronizeAsync(_appSettings); - ApplyExplorerContextMenuStatus(status); + ApplyExplorerContextMenuStatus( + status, + completingOperationId: operationId); } private void RefreshExplorerContextMenuStatus() => ApplyExplorerContextMenuStatus(_explorerContextMenuService.GetStatus()); - private void SettingsPage_InstallExplorerCertificateRequested( + private async void SettingsPage_InstallExplorerCertificateRequested( object? sender, - EventArgs e) + Views.ExplorerIntegrationOperationRequestedEventArgs e) { try { - _explorerContextMenuService.OpenCertificateInstaller(); + Process installerProcess = + _explorerContextMenuService.OpenCertificateInstaller(); + SettingsPage.SetExplorerIntegrationOperationStatus( + ResourceService.GetString("Settings.CertificateWizardOpened")); + await CertificateInstallerWorkflow.WaitForExitAsync(installerProcess); ApplyExplorerContextMenuStatus( _explorerContextMenuService.GetStatus(), - ResourceService.GetString("Settings.CertificateWizardOpened")); + ResourceService.GetString("Settings.ExplorerStatusRefreshed"), + e.OperationId); } catch (Exception ex) when ( ex is InvalidOperationException or Win32Exception) @@ -157,13 +231,14 @@ private void SettingsPage_InstallExplorerCertificateRequested( _explorerContextMenuService.GetStatus(), ResourceService.Format( "Settings.CertificateOpenFailed", - ex.Message)); + ex.Message), + e.OperationId); } } private async void SettingsPage_InstallExplorerPackageRequested( object? sender, - EventArgs e) + Views.ExplorerIntegrationOperationRequestedEventArgs e) { ExplorerContextMenuStatus status = await _explorerContextMenuService.InstallPackageAsync(); @@ -173,21 +248,162 @@ private async void SettingsPage_InstallExplorerPackageRequested( "Settings.PackageInstallFailed", status.ErrorMessage ?? ResourceService.GetString("Settings.PackageInstallDidNotComplete")); - ApplyExplorerContextMenuStatus(status, operationStatus); + ApplyExplorerContextMenuStatus( + status, + operationStatus, + e.OperationId); + } + + private async void SettingsPage_UninstallExplorerPackageRequested( + object? sender, + Views.ExplorerIntegrationOperationRequestedEventArgs e) + { + var dialog = new ContentDialog + { + Title = ResourceService.GetString("Settings.PackageUninstallConfirmTitle"), + Content = ResourceService.GetString("Settings.PackageUninstallConfirmMessage"), + PrimaryButtonText = ResourceService.GetString("Settings.UninstallPackageAction"), + CloseButtonText = ResourceService.GetString("Common.Cancel"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = Content.XamlRoot + }; + if (await ShowLocalizedDialogAsync(dialog) != ContentDialogResult.Primary) + { + ApplyExplorerContextMenuStatus( + _explorerContextMenuService.GetStatus(), + completingOperationId: e.OperationId); + return; + } + + bool disableSavedSettingBeforeUninstall; + try + { + disableSavedSettingBeforeUninstall = _explorerContextMenuService + .ShouldDisableSavedSettingBeforePackageRemoval(); + } + catch (Exception ex) when ( + ex is UnauthorizedAccessException or IOException or InvalidOperationException or + System.Runtime.InteropServices.COMException) + { + ExplorerContextMenuStatus preparationStatus = + _explorerContextMenuService.GetStatus() with + { + ErrorMessage = ex.Message + }; + ApplyExplorerContextMenuStatus( + preparationStatus, + ResourceService.Format( + "Settings.PackageUninstallFailed", + ex.Message), + e.OperationId); + return; + } + + ExplorerIntegrationUninstallResult result = + await ExplorerIntegrationUninstallWorkflow.RunAsync( + _appSettings, + disableSavedSettingBeforeUninstall, + settings => App.SettingsService.SaveAsync(settings), + () => _explorerContextMenuService.UninstallPackageAsync()); + if (result.SettingsSaveError is not null) + { + ApplyExplorerContextMenuStatus( + _explorerContextMenuService.GetStatus(), + ResourceService.Format( + "Settings.PackageUninstallSettingsSaveFailed", + result.SettingsSaveError.Message), + e.OperationId); + return; + } + + _lastSavedSettings = CloneSettings(_appSettings); + ExplorerContextMenuStatus status = result.OperationResult ?? + _explorerContextMenuService.GetStatus(); + string operationStatus; + if (!status.IsPackageRegistered && status.ErrorMessage is null) + { + operationStatus = ResourceService.GetString( + disableSavedSettingBeforeUninstall + ? "Settings.PackageUninstallSucceeded" + : "Settings.PackageUninstallSucceededSiblingRemains"); + } + else + { + operationStatus = ResourceService.Format( + "Settings.PackageUninstallFailed", + status.ErrorMessage ?? + ResourceService.GetString( + "Settings.PackageUninstallDidNotComplete")); + } + + ApplyExplorerContextMenuStatus( + status, + operationStatus, + e.OperationId); + } + + private async void SettingsPage_UninstallExplorerCertificateRequested( + object? sender, + Views.ExplorerIntegrationOperationRequestedEventArgs e) + { + var dialog = new ContentDialog + { + Title = ResourceService.GetString( + "Settings.CertificateUninstallConfirmTitle"), + Content = ResourceService.GetString( + "Settings.CertificateUninstallConfirmMessage"), + PrimaryButtonText = ResourceService.GetString( + "Settings.UninstallCertificateAction"), + CloseButtonText = ResourceService.GetString("Common.Cancel"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = Content.XamlRoot + }; + if (await ShowLocalizedDialogAsync(dialog) != ContentDialogResult.Primary) + { + ApplyExplorerContextMenuStatus( + _explorerContextMenuService.GetStatus(), + completingOperationId: e.OperationId); + return; + } + + ExplorerContextMenuStatus status = + await _explorerContextMenuService.UninstallCertificateAsync(); + string operationStatus = status.ErrorMessage is null + ? ResourceService.GetString("Settings.CertificateUninstallSucceeded") + : ResourceService.Format( + "Settings.CertificateUninstallFailed", + status.ErrorMessage); + ApplyExplorerContextMenuStatus( + status, + operationStatus, + e.OperationId); } private void SettingsPage_RefreshExplorerIntegrationRequested( object? sender, - EventArgs e) => + Views.ExplorerIntegrationOperationRequestedEventArgs e) => ApplyExplorerContextMenuStatus( _explorerContextMenuService.GetStatus(), - ResourceService.GetString("Settings.ExplorerStatusRefreshed")); + ResourceService.GetString("Settings.ExplorerStatusRefreshed"), + e.OperationId); private void ApplyExplorerContextMenuStatus( ExplorerContextMenuStatus status, - string? operationStatus = null) + string? operationStatus = null, + long? completingOperationId = null) { - string statusText = !status.IsSupported + SettingsPage.SetExplorerContextMenuState( + status, + GetExplorerMenuStatusText(status), + GetExplorerCertificateStatusText(status), + GetExplorerPackageStatusText(status), + operationStatus, + completingOperationId); + } + + private static string GetExplorerMenuStatusText( + ExplorerContextMenuStatus status) => + !status.IsSupported ? ResourceService.GetString("Settings.ExplorerMenuUnsupported") : status.ErrorMessage is not null ? ResourceService.Format( @@ -199,6 +415,9 @@ private void ApplyExplorerContextMenuStatus( ? ResourceService.GetString("Settings.ExplorerMenuInstalledDisabled") : ResourceService.GetString("Settings.ExplorerMenuDisabled"); + private static string GetExplorerCertificateStatusText( + ExplorerContextMenuStatus status) + { string certificateStatus = status.CertificateErrorMessage is not null ? ResourceService.Format( "Settings.ExplorerCertificateInvalid", @@ -223,20 +442,17 @@ private void ApplyExplorerContextMenuStatus( status.CertificateThumbprint); } - string packageStatus = status.IsPackageRegistered + return certificateStatus; + } + + private static string GetExplorerPackageStatusText( + ExplorerContextMenuStatus status) => + status.IsPackageRegistered ? ResourceService.GetString("Settings.ExplorerPackageInstalled") : status.IsPackageFileAvailable ? ResourceService.GetString("Settings.ExplorerPackageReady") : ResourceService.GetString("Settings.ExplorerPackageMissing"); - SettingsPage.SetExplorerContextMenuState( - status, - statusText, - certificateStatus, - packageStatus, - operationStatus); - } - private async Task ShowLanguageRestartDialogAsync() { var dialog = new ContentDialog diff --git a/src/ClipPort/MainWindow.xaml.cs b/src/ClipPort/MainWindow.xaml.cs index a9696ac..baa47b9 100644 --- a/src/ClipPort/MainWindow.xaml.cs +++ b/src/ClipPort/MainWindow.xaml.cs @@ -73,8 +73,12 @@ public MainWindow() SettingsPage_ExplorerContextMenuToggleRequested; SettingsPage.InstallExplorerCertificateRequested += SettingsPage_InstallExplorerCertificateRequested; + SettingsPage.UninstallExplorerCertificateRequested += + SettingsPage_UninstallExplorerCertificateRequested; SettingsPage.InstallExplorerPackageRequested += SettingsPage_InstallExplorerPackageRequested; + SettingsPage.UninstallExplorerPackageRequested += + SettingsPage_UninstallExplorerPackageRequested; SettingsPage.RefreshExplorerIntegrationRequested += SettingsPage_RefreshExplorerIntegrationRequested; RootGrid.ActualThemeChanged += RootGrid_ActualThemeChanged; @@ -451,82 +455,182 @@ private void PrepareNewJobView() } private void ShowHistoryJob(JobHistoryItem job) + { + ShowHistorySummary(job); + CurrentFileText.Text = job.ErrorMessage ?? $"{job.SourcePath} → {job.DestinationPath}"; + ShowDuplicateHistory(job); + ShowFailedFileHistory(job); + + HistoryProgressState progress = CalculateHistoryProgress(job); + ShowHistoryProgress(job, progress); + ShowHistoryPerformance(job); + ShowHistoryActions(job); + ShowHistoryOutcome(job); + } + + private void ShowHistorySummary(JobHistoryItem job) { HeroNameText.Text = job.DisplayName; SourcePathText.Text = job.SourcePath; DestinationPathText.Text = job.DestinationPath; TotalSizeText.Text = FormatBytes(job.TotalBytes); - TotalCountText.Text = job.FileCount.ToString("N0", CultureInfo.InvariantCulture); - StartTimeText.Text = job.StartedAt.ToString("MM/dd HH:mm:ss", CultureInfo.InvariantCulture); - EndTimeText.Text = job.FinishedAt?.ToString("MM/dd HH:mm:ss", CultureInfo.InvariantCulture) ?? "--"; + TotalCountText.Text = job.FileCount.ToString( + "N0", + CultureInfo.InvariantCulture); + StartTimeText.Text = job.StartedAt.ToString( + "MM/dd HH:mm:ss", + CultureInfo.InvariantCulture); + EndTimeText.Text = job.FinishedAt?.ToString( + "MM/dd HH:mm:ss", + CultureInfo.InvariantCulture) ?? "--"; DurationText.Text = job.DurationText; - CurrentFileText.Text = job.ErrorMessage ?? $"{job.SourcePath} → {job.DestinationPath}"; - ShowDuplicateHistory(job); - ShowFailedFileHistory(job); + } - bool taskFinished = job.Status is JobStatus.Completed or JobStatus.CompletedWithErrors or JobStatus.VerificationFailed; - bool copyFinished = !job.CopyEnabled || taskFinished || - (job.FileCount > 0 && job.CopiedFiles >= job.FileCount && job.CopiedBytes >= job.TotalBytes); + private static HistoryProgressState CalculateHistoryProgress(JobHistoryItem job) + { + bool taskFinished = IsTaskFinished(job); + bool copyFinished = IsCopyFinished(job, taskFinished); bool verificationFinished = job.VerificationEnabled && taskFinished; - double copyPercent = !job.CopyEnabled - ? 0 - : job.TotalBytes <= 0 + double copyPercent = CalculateCopyPercent(job, copyFinished); + double verifyPercent = CalculateVerifyPercent(job, verificationFinished); + + if (taskFinished) + { + copyPercent = job.CopyEnabled ? 100 : 0; + verifyPercent = job.VerificationEnabled ? 100 : 0; + } + + return new HistoryProgressState( + taskFinished, + copyFinished, + verificationFinished, + copyPercent, + verifyPercent); + } + + private static bool IsTaskFinished(JobHistoryItem job) => + job.Status is JobStatus.Completed or + JobStatus.CompletedWithErrors or + JobStatus.VerificationFailed; + + private static bool IsCopyFinished(JobHistoryItem job, bool taskFinished) => + !job.CopyEnabled || + taskFinished || + (job.FileCount > 0 && + job.CopiedFiles >= job.FileCount && + job.CopiedBytes >= job.TotalBytes); + + private static double CalculateCopyPercent( + JobHistoryItem job, + bool copyFinished) + { + if (!job.CopyEnabled) + { + return 0; + } + + return job.TotalBytes <= 0 ? (copyFinished ? 100 : 0) : Math.Clamp(job.CopiedBytes * 100d / job.TotalBytes, 0, 100); - double verifyPercent = job.FileCount <= 0 + } + + private static double CalculateVerifyPercent( + JobHistoryItem job, + bool verificationFinished) => + job.FileCount <= 0 ? (verificationFinished ? 100 : 0) : Math.Clamp(job.VerifiedFiles * 100d / job.FileCount, 0, 100); - if (taskFinished) + + private void ShowHistoryProgress( + JobHistoryItem job, + HistoryProgressState progress) + { + CopyProgress.Value = progress.CopyPercent; + VerifyProgress.Value = progress.VerifyPercent; + CopyProgressRow.Visibility = VisibleWhen(job.CopyEnabled); + VerifyProgressRow.Visibility = VisibleWhen(job.VerificationEnabled); + CopyProgress.Visibility = VisibleWhen(!progress.CopyFinished); + CopyCompletedBadge.Visibility = VisibleWhen(progress.CopyFinished); + CopyCompletedText.Text = GetCopyCompletedText(job); + bool verificationActive = + !progress.VerificationFinished && job.VerificationEnabled; + VerifyProgress.Visibility = VisibleWhen(verificationActive); + VerifyCompletedBadge.Visibility = VisibleWhen(!verificationActive); + VerifyCompletedText.Text = GetVerifyCompletedText(job); + VerifyCompletedBadge.Background = GetVerificationBadgeBrush(job); + OverallProgress.Value = CalculateOverallProgress(job, progress); + } + + private static Visibility VisibleWhen(bool visible) => + visible ? Visibility.Visible : Visibility.Collapsed; + + private static string GetCopyCompletedText(JobHistoryItem job) + { + if (!job.CopyEnabled) { - copyPercent = job.CopyEnabled ? 100 : 0; - verifyPercent = job.VerificationEnabled ? 100 : 0; + return ResourceService.GetString("Common.Disabled"); + } + + return job.Status == JobStatus.CompletedWithErrors + ? ResourceService.GetString("Result.CompletedWithErrors") + : ResourceService.GetString("Common.Completed"); + } + + private static string GetVerifyCompletedText(JobHistoryItem job) + { + if (!job.VerificationEnabled) + { + return ResourceService.GetString("Common.Disabled"); } - CopyProgress.Value = copyPercent; - VerifyProgress.Value = verifyPercent; - CopyProgressRow.Visibility = job.CopyEnabled - ? Visibility.Visible - : Visibility.Collapsed; - VerifyProgressRow.Visibility = job.VerificationEnabled - ? Visibility.Visible - : Visibility.Collapsed; - CopyProgress.Visibility = copyFinished ? Visibility.Collapsed : Visibility.Visible; - CopyCompletedBadge.Visibility = copyFinished ? Visibility.Visible : Visibility.Collapsed; - CopyCompletedText.Text = !job.CopyEnabled - ? ResourceService.GetString("Common.Disabled") - : job.Status == JobStatus.CompletedWithErrors - ? ResourceService.GetString("Result.CompletedWithErrors") - : ResourceService.GetString("Common.Completed"); - VerifyProgress.Visibility = verificationFinished || !job.VerificationEnabled - ? Visibility.Collapsed - : Visibility.Visible; - VerifyCompletedBadge.Visibility = verificationFinished || !job.VerificationEnabled - ? Visibility.Visible - : Visibility.Collapsed; - VerifyCompletedText.Text = !job.VerificationEnabled - ? ResourceService.GetString("Common.Disabled") - : job.Status == JobStatus.VerificationFailed - ? ResourceService.GetString("Error.VerificationFailed") - : job.Status == JobStatus.CompletedWithErrors - ? ResourceService.GetString("Result.CompletedWithErrors") - : ResourceService.GetString("Common.Completed"); - VerifyCompletedBadge.Background = new SolidColorBrush( - job.Status == JobStatus.VerificationFailed - ? ColorHelper.FromArgb(255, 0xE8, 0x46, 0x3A) // Error surface - : job.VerificationEnabled - ? ColorHelper.FromArgb(255, 0x15, 0xA8, 0x77) // Success surface - : ColorHelper.FromArgb(255, 0xE5, 0xE5, 0xE5)); - OverallProgress.Value = taskFinished - ? 100 - : !job.CopyEnabled - ? verifyPercent - : !job.VerificationEnabled - ? copyPercent - : copyPercent * 0.8 + verifyPercent * 0.2; + return job.Status switch + { + JobStatus.VerificationFailed => + ResourceService.GetString("Error.VerificationFailed"), + JobStatus.CompletedWithErrors => + ResourceService.GetString("Result.CompletedWithErrors"), + _ => ResourceService.GetString("Common.Completed") + }; + } + + private static SolidColorBrush GetVerificationBadgeBrush(JobHistoryItem job) + { + Windows.UI.Color color = job.Status == JobStatus.VerificationFailed + ? ColorHelper.FromArgb(255, 0xE8, 0x46, 0x3A) // Error surface + : job.VerificationEnabled + ? ColorHelper.FromArgb(255, 0x15, 0xA8, 0x77) // Success surface + : ColorHelper.FromArgb(255, 0xE5, 0xE5, 0xE5); + return new SolidColorBrush(color); + } + + private static double CalculateOverallProgress( + JobHistoryItem job, + HistoryProgressState progress) + { + if (progress.TaskFinished) + { + return 100; + } + if (!job.CopyEnabled) + { + return progress.VerifyPercent; + } + if (!job.VerificationEnabled) + { + return progress.CopyPercent; + } + return (progress.CopyPercent * 0.8) + + (progress.VerifyPercent * 0.2); + } + + private void ShowHistoryPerformance(JobHistoryItem job) + { CopySpeedText.Text = job.CopyEnabled && job.CopySeconds > 0 ? $"{FormatBytes(job.CopiedBytes / job.CopySeconds)}/s" : "--"; - VerifySpeedText.Text = job.VerifySeconds > 0 ? $"{FormatBytes(job.TotalBytes / job.VerifySeconds)}/s" : "--"; + VerifySpeedText.Text = job.VerifySeconds > 0 + ? $"{FormatBytes(job.TotalBytes / job.VerifySeconds)}/s" + : "--"; UpdateThroughputCharts( job.CopyByteSpeedSamples, job.CopyItemSpeedSamples, @@ -534,11 +638,18 @@ private void ShowHistoryJob(JobHistoryItem job) job.VerifyByteSpeedSamples, job.VerifyItemSpeedSamples, job.VerifyThroughputProgressSamples); - CopyTimeText.Text = job.CopyEnabled ? FormatDuration(TimeSpan.FromSeconds(job.CopySeconds)) : "--"; + CopyTimeText.Text = job.CopyEnabled + ? FormatDuration(TimeSpan.FromSeconds(job.CopySeconds)) + : "--"; VerifyTimeText.Text = FormatDuration(TimeSpan.FromSeconds(job.VerifySeconds)); - CopyCountText.Text = job.CopyEnabled ? $"{job.CopiedFiles}/{job.FileCount}" : "--"; + CopyCountText.Text = job.CopyEnabled + ? $"{job.CopiedFiles}/{job.FileCount}" + : "--"; VerifyCountText.Text = $"{job.VerifiedFiles}/{job.FileCount}"; + } + private void ShowHistoryActions(JobHistoryItem job) + { CompletionIcon.Visibility = Visibility.Visible; CompletionIcon.Glyph = job.StatusGlyph; PercentText.Visibility = Visibility.Collapsed; @@ -546,66 +657,103 @@ private void ShowHistoryJob(JobHistoryItem job) StatusText.Text = ResourceService.GetString(job.StatusText); PauseButton.Visibility = Visibility.Collapsed; CancelButton.Visibility = Visibility.Collapsed; - DeleteJobButton.Visibility = IsBatchDeletable(job) - ? Visibility.Visible - : Visibility.Collapsed; - StartVerificationButton.Visibility = CanStartVerification(job) - ? Visibility.Visible - : Visibility.Collapsed; + DeleteJobButton.Visibility = VisibleWhen(IsBatchDeletable(job)); + StartVerificationButton.Visibility = VisibleWhen(CanStartVerification(job)); StartVerificationButtonText.Text = ResourceService.GetString( job.VerificationEnabled ? "Button.Reverify" : "Button.StartVerification"); - ExportReportButton.Visibility = IsReportable(job) - ? Visibility.Visible - : Visibility.Collapsed; - RestartJobButton.Visibility = job.CanRestart - ? Visibility.Visible - : Visibility.Collapsed; + ExportReportButton.Visibility = VisibleWhen(IsReportable(job)); + RestartJobButton.Visibility = VisibleWhen(job.CanRestart); RestartJobButtonText.Text = ResourceService.GetString("Button.Restart"); StartButton.IsEnabled = false; StartButton.Visibility = Visibility.Collapsed; + } - bool succeeded = job.Status == JobStatus.Completed; - SolidColorBrush stateBrush = new(succeeded - ? ColorHelper.FromArgb(255, 0x15, 0xA8, 0x77) // Success - : ColorHelper.FromArgb(255, 0xE8, 0x46, 0x3A)); // Error + private void ShowHistoryOutcome(JobHistoryItem job) + { + SolidColorBrush stateBrush = GetHistoryStateBrush(job); CompletionIcon.Foreground = stateBrush; StatusText.Foreground = stateBrush; + PhaseText.Text = GetHistoryPhaseText(job); + LogText.Text = GetHistoryLogText(job); + } - PhaseText.Text = job.Status switch + private static SolidColorBrush GetHistoryStateBrush(JobHistoryItem job) => + new(job.Status == JobStatus.Completed + ? ColorHelper.FromArgb(255, 0x15, 0xA8, 0x77) // Success + : ColorHelper.FromArgb(255, 0xE8, 0x46, 0x3A)); // Error + + private static string GetHistoryPhaseText(JobHistoryItem job) => + job.Status switch { JobStatus.CompletedWithErrors => job.CopyEnabled ? ResourceService.GetString("Result.CopiedFailedFilesSkipped") : ResourceService.GetString("Result.VerifiedFailedFilesSkipped"), - JobStatus.Completed => job.CopyEnabled && job.VerificationEnabled - ? ResourceService.GetString("Result.CopyAndVerifyCompleted") - : job.CopyEnabled - ? ResourceService.GetString("Result.CopyCompleted") - : ResourceService.GetString("Result.SHA256VerificationCompleted"), + JobStatus.Completed => GetCompletedHistoryPhaseText(job), JobStatus.VerificationFailed => job.CopyEnabled ? ResourceService.GetString("Result.CopyCompletedButVerifyFailed") : ResourceService.GetString("Result.SHA256VerificationFailed"), - JobStatus.Cancelled => ResourceService.GetString("Error.TaskCancelledKeptShort"), - JobStatus.Interrupted => ResourceService.GetString("Error.AppExitedBeforeFinishShort"), - JobStatus.Failed => ResourceService.GetString("Error.TaskExecutionFailed"), + JobStatus.Cancelled => + ResourceService.GetString("Error.TaskCancelledKeptShort"), + JobStatus.Interrupted => + ResourceService.GetString("Error.AppExitedBeforeFinishShort"), + JobStatus.Failed => + ResourceService.GetString("Error.TaskExecutionFailed"), _ => ResourceService.GetString("Dialog.TaskRecord") }; - LogText.Text = job.Status switch - { - JobStatus.CompletedWithErrors => ResourceService.Format("Format.TaskPartiallyCompletedSkipped", job.FailedFiles.Count.ToString("N0")), - JobStatus.Completed => job.CopyEnabled && job.VerificationEnabled - ? ResourceService.Format("Format.TaskCompletedCopiedVerified", job.FileCount.ToString("N0")) - : job.CopyEnabled - ? ResourceService.Format("Format.CopyCompletedCopied", job.FileCount.ToString("N0")) - : ResourceService.Format("Format.VerificationCompletedAllPassed", job.FileCount.ToString("N0")), - JobStatus.VerificationFailed => job.ErrorMessage ?? ResourceService.GetString("Error.VerificationMismatch"), - JobStatus.Cancelled => ResourceService.GetString("Error.TaskCancelledKept"), - JobStatus.Interrupted => ResourceService.GetString("Error.InterruptedRecord"), - _ => job.ErrorMessage ?? ResourceService.GetString("Error.TaskNotFinished") + + private static string GetCompletedHistoryPhaseText(JobHistoryItem job) + { + if (!job.CopyEnabled) + { + return ResourceService.GetString("Result.SHA256VerificationCompleted"); + } + + return job.VerificationEnabled + ? ResourceService.GetString("Result.CopyAndVerifyCompleted") + : ResourceService.GetString("Result.CopyCompleted"); + } + + private static string GetHistoryLogText(JobHistoryItem job) => + job.Status switch + { + JobStatus.CompletedWithErrors => ResourceService.Format( + "Format.TaskPartiallyCompletedSkipped", + job.FailedFiles.Count.ToString("N0")), + JobStatus.Completed => GetCompletedHistoryLogText(job), + JobStatus.VerificationFailed => job.ErrorMessage ?? + ResourceService.GetString("Error.VerificationMismatch"), + JobStatus.Cancelled => + ResourceService.GetString("Error.TaskCancelledKept"), + JobStatus.Interrupted => + ResourceService.GetString("Error.InterruptedRecord"), + _ => job.ErrorMessage ?? + ResourceService.GetString("Error.TaskNotFinished") }; + + private static string GetCompletedHistoryLogText(JobHistoryItem job) + { + string fileCount = job.FileCount.ToString("N0"); + if (!job.CopyEnabled) + { + return ResourceService.Format( + "Format.VerificationCompletedAllPassed", + fileCount); + } + + return job.VerificationEnabled + ? ResourceService.Format("Format.TaskCompletedCopiedVerified", fileCount) + : ResourceService.Format("Format.CopyCompletedCopied", fileCount); } + private readonly record struct HistoryProgressState( + bool TaskFinished, + bool CopyFinished, + bool VerificationFinished, + double CopyPercent, + double VerifyPercent); + private void MultiSelectButton_Click(object sender, RoutedEventArgs e) { if (_isMultiSelectMode) diff --git a/src/ClipPort/Services/CertificateInstallerWorkflow.cs b/src/ClipPort/Services/CertificateInstallerWorkflow.cs new file mode 100644 index 0000000..f0a9a01 --- /dev/null +++ b/src/ClipPort/Services/CertificateInstallerWorkflow.cs @@ -0,0 +1,19 @@ +using System.Diagnostics; + +namespace ClipPort.Services; + +internal static class CertificateInstallerWorkflow +{ + public static Task WaitForExitAsync(Process process) => + WaitForExitAsync(process, static currentProcess => currentProcess.WaitForExitAsync()); + + internal static async Task WaitForExitAsync( + Process process, + Func waitForExitAsync) + { + using (process) + { + await waitForExitAsync(process); + } + } +} diff --git a/src/ClipPort/Services/CertificateStoreSearch.cs b/src/ClipPort/Services/CertificateStoreSearch.cs new file mode 100644 index 0000000..d0a8369 --- /dev/null +++ b/src/ClipPort/Services/CertificateStoreSearch.cs @@ -0,0 +1,22 @@ +namespace ClipPort.Services; + +internal static class CertificateStoreSearch +{ + public static List FindMatches( + IEnumerable targets, + Func containsCertificate) + { + var matches = new List(); + foreach (TTarget target in targets) + { + // Access failures must remain visible to callers so removal cannot + // report success when a certificate store could not be inspected. + if (containsCertificate(target)) + { + matches.Add(target); + } + } + + return matches; + } +} diff --git a/src/ClipPort/Services/ExplorerContextMenuConfigurationPolicy.cs b/src/ClipPort/Services/ExplorerContextMenuConfigurationPolicy.cs new file mode 100644 index 0000000..74d4a95 --- /dev/null +++ b/src/ClipPort/Services/ExplorerContextMenuConfigurationPolicy.cs @@ -0,0 +1,115 @@ +namespace ClipPort.Services; + +public sealed record ExplorerContextMenuConfiguration( + bool Enabled, + string Language, + string InstallDirectory); + +public static class ExplorerContextMenuConfigurationPolicy +{ + public static bool HasSiblingRegistration( + IEnumerable registrations, + string expectedPackageName, + string currentExternalPath) => + registrations.Any(registration => + string.Equals( + registration.Name, + expectedPackageName, + StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(registration.EffectiveExternalPath) && + !ExplorerPackageIdentity.ExternalPathsEqual( + registration.EffectiveExternalPath, + currentExternalPath)); + + public static bool ShouldDeferSynchronizationToConfigurationOwner( + ExplorerContextMenuConfiguration? configuration, + IEnumerable registrations, + string expectedPackageName, + string currentExternalPath) + { + List packageRegistrations = registrations + .Where(registration => + string.Equals( + registration.Name, + expectedPackageName, + StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(registration.EffectiveExternalPath)) + .ToList(); + bool currentPackageRegistered = packageRegistrations.Any(registration => + ExplorerPackageIdentity.ExternalPathsEqual( + registration.EffectiveExternalPath, + currentExternalPath)); + + if (!currentPackageRegistered && packageRegistrations.Count > 0) + { + return true; + } + + return configuration is not null && + !ExplorerPackageIdentity.ExternalPathsEqual( + configuration.InstallDirectory, + currentExternalPath) && + packageRegistrations.Any(registration => + ExplorerPackageIdentity.ExternalPathsEqual( + registration.EffectiveExternalPath, + configuration.InstallDirectory)); + } + + public static bool ShouldDisableBeforeRemoval( + ExplorerContextMenuConfiguration? configuration, + IEnumerable registrations, + string expectedPackageName, + string removedExternalPath) => + configuration is not null && + !registrations.Any(registration => + string.Equals( + registration.Name, + expectedPackageName, + StringComparison.OrdinalIgnoreCase) && + !ExplorerPackageIdentity.ExternalPathsEqual( + registration.EffectiveExternalPath, + removedExternalPath) && + ExplorerPackageIdentity.ExternalPathsEqual( + registration.EffectiveExternalPath, + configuration.InstallDirectory)); + + public static ExplorerContextMenuConfiguration? ReconcileAfterRemoval( + ExplorerContextMenuConfiguration? configuration, + IEnumerable remainingRegistrations, + string expectedPackageName) + { + if (configuration is null) + { + return null; + } + + List candidates = remainingRegistrations + .Where(registration => + string.Equals( + registration.Name, + expectedPackageName, + StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(registration.EffectiveExternalPath)) + .OrderBy( + registration => registration.EffectiveExternalPath, + StringComparer.OrdinalIgnoreCase) + .ThenBy( + registration => registration.Publisher, + StringComparer.OrdinalIgnoreCase) + .ToList(); + + ExplorerPackageRegistration? replacement = candidates.FirstOrDefault( + registration => ExplorerPackageIdentity.ExternalPathsEqual( + registration.EffectiveExternalPath, + configuration.InstallDirectory)) ?? + candidates.FirstOrDefault(); + + return replacement is null + ? null + : configuration with + { + InstallDirectory = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(replacement.EffectiveExternalPath)) + }; + } +} diff --git a/src/ClipPort/Services/ExplorerContextMenuService.cs b/src/ClipPort/Services/ExplorerContextMenuService.cs index 533c7cf..7e17f64 100644 --- a/src/ClipPort/Services/ExplorerContextMenuService.cs +++ b/src/ClipPort/Services/ExplorerContextMenuService.cs @@ -1,9 +1,11 @@ using ClipPort.Models; using Microsoft.Win32; +using System.ComponentModel; using System.Diagnostics; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Runtime.Versioning; +using System.Text; using Windows.Management.Deployment; namespace ClipPort.Services; @@ -33,10 +35,26 @@ public sealed class ExplorerContextMenuService public const string RegistryPath = @"Software\ClipPort\ExplorerContextMenu"; public const string PackageFileName = "ClipPort.ShellIntegration.msix"; public const string CertificateFileName = "ClipPort.ShellIntegration.cer"; + public const string DevelopmentRegistrationDirectoryName = + "ShellIntegration.Development"; public bool IsSupported => OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000); + public bool ShouldDisableSavedSettingBeforePackageRemoval() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return true; + } + + var packageManager = new PackageManager(); + return !ExplorerContextMenuConfigurationPolicy.HasSiblingRegistration( + GetPackageRegistrations(packageManager), + PackageIdentityName, + AppContext.BaseDirectory); + } + public ExplorerContextMenuStatus GetStatus() { if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) @@ -54,13 +72,14 @@ public ExplorerContextMenuStatus GetStatus() } catch (Exception ex) when ( ex is UnauthorizedAccessException or IOException or InvalidOperationException or - CryptographicException) + CryptographicException or InvalidDataException or + System.Runtime.InteropServices.COMException) { return CreateStatus(true, false, false, ex.Message); } } - public void OpenCertificateInstaller() + public Process OpenCertificateInstaller() { string certificatePath = GetCertificatePath(); if (!File.Exists(certificatePath)) @@ -69,12 +88,13 @@ public void OpenCertificateInstaller() $"Shell integration certificate is missing: {certificatePath}"); } - Process.Start(new ProcessStartInfo(certificatePath) + return Process.Start(new ProcessStartInfo(certificatePath) { // Windows owns the certificate wizard and the trust decision. ClipPort // must never add a certificate to a trusted store silently. UseShellExecute = true - }); + }) ?? throw new InvalidOperationException( + "Windows did not start the certificate installer."); } public async Task InstallPackageAsync() @@ -102,7 +122,8 @@ public async Task InstallPackageAsync() } catch (Exception ex) when ( ex is UnauthorizedAccessException or IOException or InvalidOperationException or - System.Runtime.InteropServices.COMException or CryptographicException) + System.Runtime.InteropServices.COMException or CryptographicException or + InvalidDataException) { return CreateStatus( true, @@ -112,9 +133,7 @@ ex is UnauthorizedAccessException or IOException or InvalidOperationException or } } - public async Task SetEnabledAsync( - bool enabled, - AppLanguage language) + public async Task UninstallPackageAsync() { if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) { @@ -123,35 +142,145 @@ public async Task SetEnabledAsync( try { - bool packageRegistered = IsPackageRegistered(); - if (enabled && !packageRegistered) - { - ExplorerContextMenuStatus installationStatus = await InstallPackageAsync(); - if (installationStatus.ErrorMessage is not null || - !installationStatus.IsPackageRegistered) + var packageManager = new PackageManager(); + ExplorerContextMenuConfiguration? configuration = + ReadExplorerContextMenuConfiguration(); + List registrations = + GetPackageRegistrations(packageManager); + await ExplorerIntegrationPackageRemovalWorkflow.RunAsync( + () => { - return installationStatus; - } - packageRegistered = true; - } + if (ExplorerContextMenuConfigurationPolicy.ShouldDisableBeforeRemoval( + configuration, + registrations, + PackageIdentityName, + AppContext.BaseDirectory)) + { + WriteExplorerContextMenuEnabledState(false); + } + }, + () => RemoveBundledPackagesAsync(packageManager), + () => ReconcileExplorerContextMenuConfiguration( + packageManager, + configuration)); - using RegistryKey key = Registry.CurrentUser.CreateSubKey(RegistryPath, true); - key.SetValue("Enabled", enabled ? 1 : 0, RegistryValueKind.DWord); - key.SetValue( - "Language", - AppLanguages.Get(language).LanguageTag, - RegistryValueKind.String); - key.SetValue( - "InstallDirectory", - Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory), - RegistryValueKind.String); + return CreateStatus(true, false, false); + } + catch (Exception ex) when ( + ex is UnauthorizedAccessException or IOException or InvalidOperationException or + System.Runtime.InteropServices.COMException or InvalidDataException or + CryptographicException) + { + bool packageRegistered = IsPackageRegisteredSafe(); return CreateStatus( true, packageRegistered, - enabled && packageRegistered); + packageRegistered && ReadEnabledStateSafe(), + ex.Message); + } + } + + private static async Task RemoveBundledPackagesAsync( + PackageManager packageManager) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + throw new PlatformNotSupportedException( + "Shell integration package removal requires Windows 10 version 2004 or later."); + } + + ExplorerPackageIdentity packageIdentity = + ReadAvailablePackageIdentity(packageManager); + List packages = + FindRegisteredPackagesForCurrentDirectory(packageManager, packageIdentity); + + foreach (Windows.ApplicationModel.Package package in packages) + { + DeploymentResult result = + await packageManager.RemovePackageAsync(package.Id.FullName); + ThrowIfDeploymentFailed(result); + } + + if (IsPackageRegistered(packageIdentity)) + { + throw new InvalidOperationException( + "Windows still reports the shell integration package after removal."); + } + } + + [SupportedOSPlatform("windows10.0.19041.0")] + private static List + FindRegisteredPackagesForCurrentDirectory( + PackageManager packageManager, + ExplorerPackageIdentity packageIdentity) => + packageManager + .FindPackagesForUser(string.Empty) + .Where(package => packageIdentity.MatchesRegistration( + new ExplorerPackageRegistration( + package.Id.Name, + package.Id.Publisher, + package.EffectiveExternalPath), + AppContext.BaseDirectory)) + .ToList(); + + public async Task UninstallCertificateAsync() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) + { + return CreateStatus(false, false, false); + } + + try + { + string certificatePath = GetCertificatePath(); + if (!File.Exists(certificatePath)) + { + throw new InvalidOperationException( + $"Shell integration certificate is missing: {certificatePath}"); + } + + using var certificate = new X509Certificate2(certificatePath); + ExplorerPackageIdentity certificateIdentity = + ExplorerPackageIdentity.FromCertificate( + PackageIdentityName, + certificate); + if (IsPackageRegisteredForAnyExternalPath(certificateIdentity)) + { + throw new InvalidOperationException( + "Uninstall the shell integration package before removing its certificate."); + } + + List targets = FindCertificateStoreTargets( + certificate.Thumbprint); + List machineTargets = targets + .Where(target => target.Location == StoreLocation.LocalMachine) + .ToList(); + + foreach (CertificateStoreTarget target in targets.Where( + target => target.Location == StoreLocation.CurrentUser)) + { + RemoveCertificateFromCurrentUserStore(certificate, target.StoreName); + } + + if (machineTargets.Count > 0) + { + await RemoveCertificateFromLocalMachineStoresAsync( + certificate.Thumbprint, + machineTargets, + certificate.Subject); + } + + if (FindCertificateStoreTargets(certificate.Thumbprint).Count > 0) + { + throw new InvalidOperationException( + "The certificate is still present in a Windows certificate store."); + } + + return CreateStatus(true, false, false); } catch (Exception ex) when ( ex is UnauthorizedAccessException or IOException or InvalidOperationException or + CryptographicException or Win32Exception or System.Runtime.InteropServices.COMException) { return CreateStatus( @@ -162,13 +291,180 @@ ex is UnauthorizedAccessException or IOException or InvalidOperationException or } } + public async Task SetEnabledAsync( + bool enabled, + AppLanguage language) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) + { + return CreateStatus(false, false, false); + } + + try + { + return await SetEnabledOnSupportedWindowsAsync(enabled, language); + } + catch (Exception ex) when ( + ex is UnauthorizedAccessException or IOException or InvalidOperationException or + System.Runtime.InteropServices.COMException or CryptographicException or + InvalidDataException) + { + return CreateStatus( + true, + IsPackageRegisteredSafe(), + false, + ex.Message); + } + } + + private async Task SetEnabledOnSupportedWindowsAsync( + bool enabled, + AppLanguage language) + { + bool packageRegistered = IsPackageRegistered(); + if (enabled && !packageRegistered) + { + ExplorerContextMenuStatus installationStatus = await InstallPackageAsync(); + if (installationStatus.ErrorMessage is not null || + !installationStatus.IsPackageRegistered) + { + return installationStatus; + } + packageRegistered = true; + } + + WriteExplorerContextMenuSettings(enabled, language); + return CreateStatus( + true, + packageRegistered, + enabled && packageRegistered); + } + + private static void WriteExplorerContextMenuSettings( + bool enabled, + AppLanguage language) => + WriteExplorerContextMenuConfiguration( + new ExplorerContextMenuConfiguration( + enabled, + AppLanguages.Get(language).LanguageTag, + Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory))); + + private static ExplorerContextMenuConfiguration? + ReadExplorerContextMenuConfiguration() + { + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(RegistryPath); + if (key is null) + { + return null; + } + + return new ExplorerContextMenuConfiguration( + key.GetValue("Enabled", 0) is int enabled && enabled == 1, + key.GetValue("Language") as string ?? "zh-CN", + key.GetValue("InstallDirectory") as string ?? string.Empty); + } + + private static void WriteExplorerContextMenuConfiguration( + ExplorerContextMenuConfiguration configuration) + { + using RegistryKey key = Registry.CurrentUser.CreateSubKey(RegistryPath, true); + WriteExplorerContextMenuEnabledState(key, configuration.Enabled); + key.SetValue( + "Language", + configuration.Language, + RegistryValueKind.String); + key.SetValue( + "InstallDirectory", + configuration.InstallDirectory, + RegistryValueKind.String); + } + + private static void ReconcileExplorerContextMenuConfiguration( + PackageManager packageManager, + ExplorerContextMenuConfiguration? configuration) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + throw new PlatformNotSupportedException( + "Shell integration package maintenance requires Windows 10 version 2004 or later."); + } + + ExplorerContextMenuConfiguration? reconciled = + ExplorerContextMenuConfigurationPolicy.ReconcileAfterRemoval( + configuration, + GetPackageRegistrations(packageManager), + PackageIdentityName); + if (reconciled is null) + { + Registry.CurrentUser.DeleteSubKeyTree( + RegistryPath, + throwOnMissingSubKey: false); + return; + } + + WriteExplorerContextMenuConfiguration(reconciled); + } + + private static void WriteExplorerContextMenuEnabledState(bool enabled) + { + using RegistryKey key = Registry.CurrentUser.CreateSubKey(RegistryPath, true); + WriteExplorerContextMenuEnabledState(key, enabled); + } + + private static void WriteExplorerContextMenuEnabledState( + RegistryKey key, + bool enabled) => + key.SetValue("Enabled", enabled ? 1 : 0, RegistryValueKind.DWord); + public async Task SynchronizeAsync(AppSettings settings) { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) + { + return CreateStatus(false, false, false); + } + + try + { + if (ShouldDeferExplorerSynchronization()) + { + // Another registered ClipPort copy owns the shared shell menu. + // Startup must not reinstall this copy or overwrite that owner. + return GetStatus(); + } + } + catch (Exception ex) when ( + ex is UnauthorizedAccessException or IOException or InvalidOperationException or + System.Runtime.InteropServices.COMException or ArgumentException or + NotSupportedException) + { + return CreateStatus( + true, + IsPackageRegisteredSafe(), + false, + ex.Message); + } + return await SetEnabledAsync( settings.ExplorerContextMenuEnabled, settings.Language); } + public bool ShouldDeferExplorerSynchronization() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return false; + } + + var packageManager = new PackageManager(); + return ExplorerContextMenuConfigurationPolicy + .ShouldDeferSynchronizationToConfigurationOwner( + ReadExplorerContextMenuConfiguration(), + GetPackageRegistrations(packageManager), + PackageIdentityName, + AppContext.BaseDirectory); + } + private static bool IsPackageRegisteredSafe() { try @@ -183,20 +479,144 @@ private static bool IsPackageRegisteredSafe() private static bool IsPackageRegistered() { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return false; + } + var packageManager = new PackageManager(); - return packageManager.FindPackagesForUser(string.Empty) - .Any(package => string.Equals( - package.Id.Name, - PackageIdentityName, - StringComparison.OrdinalIgnoreCase)); + try + { + // Status and maintenance actions must select the same package. + // A same-name package from another publisher or application path + // must not make this installation appear removable. + ExplorerPackageIdentity identity = + ReadAvailablePackageIdentity(packageManager); + return IsPackageRegistered(packageManager, identity); + } + catch (FileNotFoundException) + { + return false; + } } + private static bool IsPackageRegistered(ExplorerPackageIdentity identity) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return false; + } + + var packageManager = new PackageManager(); + return IsPackageRegistered(packageManager, identity); + } + + private static bool IsPackageRegisteredForAnyExternalPath( + ExplorerPackageIdentity identity) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return false; + } + + var packageManager = new PackageManager(); + return identity.MatchesAny(GetPackageRegistrations(packageManager)); + } + + [SupportedOSPlatform("windows10.0.19041.0")] + private static bool IsPackageRegistered( + PackageManager packageManager, + ExplorerPackageIdentity identity) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return false; + } + + return identity.MatchesAny( + packageManager.FindPackagesForUser(string.Empty) + .Select(package => new ExplorerPackageRegistration( + package.Id.Name, + package.Id.Publisher, + package.EffectiveExternalPath)), + AppContext.BaseDirectory); + } + + private static List GetPackageRegistrations( + PackageManager packageManager) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + { + return []; + } + + return GetPackageRegistrationsOnSupportedWindows(packageManager); + } + + [SupportedOSPlatform("windows10.0.19041.0")] + private static List + GetPackageRegistrationsOnSupportedWindows( + PackageManager packageManager) => + packageManager.FindPackagesForUser(string.Empty) + .Select(package => new ExplorerPackageRegistration( + package.Id.Name, + package.Id.Publisher, + package.EffectiveExternalPath)) + .ToList(); + private static bool ReadEnabledState() { using RegistryKey? key = Registry.CurrentUser.OpenSubKey(RegistryPath); return Convert.ToInt32(key?.GetValue("Enabled", 0)) == 1; } + private static bool ReadEnabledStateSafe() + { + try + { + return ReadEnabledState(); + } + catch (Exception ex) when ( + ex is UnauthorizedAccessException or IOException or InvalidOperationException) + { + return false; + } + } + + [SupportedOSPlatform("windows10.0.19041.0")] + private static ExplorerPackageIdentity ReadAvailablePackageIdentity( + PackageManager packageManager) + { + // The package registered for this exact external directory is stronger + // evidence than files left beside the application by another workflow. + ExplorerPackageIdentity? registeredIdentity = + ExplorerPackageIdentity.FindRegisteredForExternalPath( + packageManager.FindPackagesForUser(string.Empty) + .Where(package => string.Equals( + package.Id.Name, + PackageIdentityName, + StringComparison.OrdinalIgnoreCase)) + .Select(package => new ExplorerPackageRegistration( + package.Id.Name, + package.Id.Publisher, + package.EffectiveExternalPath)), + PackageIdentityName, + AppContext.BaseDirectory); + if (registeredIdentity is not null) + { + return registeredIdentity; + } + + return ExplorerPackageIdentity.Resolve( + GetPackagePath(), + Path.Combine( + AppContext.BaseDirectory, + DevelopmentRegistrationDirectoryName, + "AppxManifest.xml"), + GetCertificatePath(), + PackageIdentityName); + } + private static ExplorerContextMenuStatus CreateStatus( bool supported, bool packageRegistered, @@ -218,8 +638,12 @@ private static ExplorerContextMenuStatus CreateStatus( thumbprint = certificate.Thumbprint; trustScope = GetTrustScope(certificate); } - catch (CryptographicException ex) + catch (Exception ex) when ( + ex is CryptographicException or UnauthorizedAccessException or + IOException or InvalidOperationException) { + // A failing store must be reported through the status instead + // of re-enumerated by the caller's error handling. certificateErrorMessage = ex.Message; } } @@ -260,26 +684,139 @@ private static bool ContainsCertificate( { foreach (StoreName storeName in new[] { StoreName.Root, StoreName.TrustedPeople }) { - try + if (ContainsCertificate(location, storeName, thumbprint)) { - using var store = new X509Store(storeName, location); - store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); - if (store.Certificates.Find( - X509FindType.FindByThumbprint, - thumbprint, - validOnly: false).Count > 0) - { - return true; - } + return true; + } + } + + return false; + } + + private static List FindCertificateStoreTargets( + string thumbprint) + { + IEnumerable targets = + from location in new[] + { + StoreLocation.CurrentUser, + StoreLocation.LocalMachine } - catch (CryptographicException) + from storeName in new[] { - // A missing or inaccessible store is treated as not trusted. + StoreName.Root, + StoreName.TrustedPeople } + select new CertificateStoreTarget(location, storeName); + + return CertificateStoreSearch.FindMatches( + targets, + target => ContainsCertificate( + target.Location, + target.StoreName, + thumbprint)); + } + + private static bool ContainsCertificate( + StoreLocation location, + StoreName storeName, + string thumbprint) + { + using var store = new X509Store(storeName, location); + store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); + return store.Certificates.Find( + X509FindType.FindByThumbprint, + thumbprint, + validOnly: false).Count > 0; + } + + private static void RemoveCertificateFromCurrentUserStore( + X509Certificate2 certificate, + StoreName storeName) + { + using var store = new X509Store(storeName, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite | OpenFlags.OpenExistingOnly); + X509Certificate2Collection matches = store.Certificates.Find( + X509FindType.FindByThumbprint, + certificate.Thumbprint, + validOnly: false); + store.RemoveRange(matches); + } + + private static async Task RemoveCertificateFromLocalMachineStoresAsync( + string thumbprint, + IReadOnlyList targets, + string certificateSubject) + { + string systemDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.System); + string certutilPath = Path.Combine(systemDirectory, "certutil.exe"); + if (!File.Exists(certutilPath)) + { + throw new InvalidOperationException( + $"Windows certificate utility is missing: {certutilPath}"); + } + + string quotedCertutilPath = ToPowerShellSingleQuotedLiteral(certutilPath); + string quotedThumbprint = ToPowerShellSingleQuotedLiteral(thumbprint); + string quotedPackageName = ToPowerShellSingleQuotedLiteral( + PackageIdentityName); + string quotedPublisher = ToPowerShellSingleQuotedLiteral( + certificateSubject); + // The all-user package check requires elevation, so it runs inside the + // same elevated helper that owns the machine store removal. The script + // is passed with -EncodedCommand so publisher DN characters (including + // quotes) survive Windows command-line parsing unchanged. + string guardCommands = + "try { " + + $"$clipPortPackages = @(Get-AppxPackage -Name {quotedPackageName} " + + "-AllUsers -ErrorAction Stop); " + + "} catch { exit 3 }; " + + $"if ($clipPortPackages | Where-Object {{ $_.Publisher -eq {quotedPublisher} }}) " + + "{{ exit 2 }}; "; + string removalCommands = string.Join( + "; ", + targets.Select(target => + $"& {quotedCertutilPath} -delstore {target.StoreName} {quotedThumbprint}; " + + "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }")); + string powerShellScript = guardCommands + removalCommands; + string encodedScript = Convert.ToBase64String( + Encoding.Unicode.GetBytes(powerShellScript)); + string powerShellPath = Path.Combine( + systemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + var startInfo = new ProcessStartInfo(powerShellPath) + { + Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encodedScript}", + UseShellExecute = true, + Verb = "runas" + }; + using Process? process = Process.Start(startInfo); + if (process is null) + { + throw new InvalidOperationException( + "Windows did not start the elevated certificate removal process."); + } + + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + { + string message = process.ExitCode switch + { + 2 => "Uninstall the shell integration package for every user before removing its certificate.", + 3 => "Could not verify other users' registrations before removing the certificate.", + _ => $"Certificate removal exited with code {process.ExitCode}." + }; + throw new InvalidOperationException( + message); } - return false; } + private static string ToPowerShellSingleQuotedLiteral(string value) => + $"'{value.Replace("'", "''", StringComparison.Ordinal)}'"; + private static string GetPackagePath() => Path.Combine(AppContext.BaseDirectory, PackageFileName); @@ -306,14 +843,25 @@ private static async Task RegisterPackageAsync() DeploymentResult result = await packageManager.AddPackageByUriAsync( new Uri(packagePath), options); - if (result.ExtendedErrorCode is Exception extendedError && - extendedError.HResult != 0) + ThrowIfDeploymentFailed(result); + } + + private static void ThrowIfDeploymentFailed(DeploymentResult result) + { + if (result.ExtendedErrorCode is not Exception extendedError || + extendedError.HResult == 0) { - throw new InvalidOperationException( - string.IsNullOrWhiteSpace(result.ErrorText) - ? extendedError.Message - : result.ErrorText, - extendedError); + return; } + + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(result.ErrorText) + ? extendedError.Message + : result.ErrorText, + extendedError); } + + private sealed record CertificateStoreTarget( + StoreLocation Location, + StoreName StoreName); } diff --git a/src/ClipPort/Services/ExplorerIntegrationOperationGate.cs b/src/ClipPort/Services/ExplorerIntegrationOperationGate.cs new file mode 100644 index 0000000..838f8a2 --- /dev/null +++ b/src/ClipPort/Services/ExplorerIntegrationOperationGate.cs @@ -0,0 +1,34 @@ +namespace ClipPort.Services; + +public sealed class ExplorerIntegrationOperationGate +{ + private long _nextOperationId; + private long _activeOperationId; + + public bool IsBusy => Volatile.Read(ref _activeOperationId) != 0; + + public bool CanUpdateSharedConfiguration => !IsBusy; + + public bool TryBegin(out long operationId) + { + long candidateId = Interlocked.Increment(ref _nextOperationId); + if (Interlocked.CompareExchange( + ref _activeOperationId, + candidateId, + comparand: 0) == 0) + { + operationId = candidateId; + return true; + } + + operationId = 0; + return false; + } + + public bool Complete(long operationId) => + operationId > 0 && + Interlocked.CompareExchange( + ref _activeOperationId, + value: 0, + comparand: operationId) == operationId; +} diff --git a/src/ClipPort/Services/ExplorerIntegrationPackageRemovalWorkflow.cs b/src/ClipPort/Services/ExplorerIntegrationPackageRemovalWorkflow.cs new file mode 100644 index 0000000..f6d5099 --- /dev/null +++ b/src/ClipPort/Services/ExplorerIntegrationPackageRemovalWorkflow.cs @@ -0,0 +1,16 @@ +namespace ClipPort.Services; + +public static class ExplorerIntegrationPackageRemovalWorkflow +{ + public static async Task RunAsync( + Action disableLiveState, + Func removePackageAsync, + Action clearConfiguration) + { + // The shell extension reads this state for every menu query, so it + // must be disabled before Windows starts an operation that may fail. + disableLiveState(); + await removePackageAsync(); + clearConfiguration(); + } +} diff --git a/src/ClipPort/Services/ExplorerIntegrationUninstallWorkflow.cs b/src/ClipPort/Services/ExplorerIntegrationUninstallWorkflow.cs new file mode 100644 index 0000000..4996d5b --- /dev/null +++ b/src/ClipPort/Services/ExplorerIntegrationUninstallWorkflow.cs @@ -0,0 +1,38 @@ +using ClipPort.Models; + +namespace ClipPort.Services; + +public sealed record ExplorerIntegrationUninstallResult( + T? OperationResult, + Exception? SettingsSaveError); + +public static class ExplorerIntegrationUninstallWorkflow +{ + public static async Task> RunAsync( + AppSettings settings, + bool disableSavedSettingBeforeUninstall, + Func saveSettingsAsync, + Func> uninstallAsync) + { + if (disableSavedSettingBeforeUninstall) + { + bool previousEnabled = settings.ExplorerContextMenuEnabled; + settings.ExplorerContextMenuEnabled = false; + try + { + // Persist the disabled state before removing the final package + // so startup cannot restore an uninstall from stale settings. + await saveSettingsAsync(settings); + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException) + { + settings.ExplorerContextMenuEnabled = previousEnabled; + return new ExplorerIntegrationUninstallResult(default, ex); + } + } + + T operationResult = await uninstallAsync(); + return new ExplorerIntegrationUninstallResult(operationResult, null); + } +} diff --git a/src/ClipPort/Services/ExplorerPackageIdentity.cs b/src/ClipPort/Services/ExplorerPackageIdentity.cs new file mode 100644 index 0000000..9d99728 --- /dev/null +++ b/src/ClipPort/Services/ExplorerPackageIdentity.cs @@ -0,0 +1,169 @@ +using System.IO.Compression; +using System.Security.Cryptography.X509Certificates; +using System.Xml; +using System.Xml.Linq; + +namespace ClipPort.Services; + +public sealed record ExplorerPackageRegistration( + string Name, + string Publisher, + string EffectiveExternalPath); + +public sealed record ExplorerPackageIdentity(string Name, string Publisher) +{ + public bool Matches(string name, string publisher) => + string.Equals(Name, name, StringComparison.OrdinalIgnoreCase) && + string.Equals(Publisher, publisher, StringComparison.OrdinalIgnoreCase); + + public bool MatchesRegistration( + ExplorerPackageRegistration registration, + string expectedExternalPath) => + Matches(registration.Name, registration.Publisher) && + ExternalPathsEqual(registration.EffectiveExternalPath, expectedExternalPath); + + public bool MatchesAny( + IEnumerable registrations) => + registrations.Any(registration => Matches( + registration.Name, + registration.Publisher)); + + public bool MatchesAny( + IEnumerable registrations, + string expectedExternalPath) => + registrations.Any(registration => + MatchesRegistration(registration, expectedExternalPath)); + + public static ExplorerPackageIdentity ReadManifest(Stream manifestStream) + { + XDocument manifest; + try + { + manifest = XDocument.Load(manifestStream); + } + catch (XmlException ex) + { + // Normalize malformed XML to the same package-data failure that + // callers already surface as an operation status. + throw new InvalidDataException( + "The shell integration package manifest is malformed.", + ex); + } + + XElement identity = manifest.Root? + .Elements() + .FirstOrDefault(element => element.Name.LocalName == "Identity") ?? + throw new InvalidDataException( + "The shell integration package manifest has no Identity element."); + string name = identity.Attribute("Name")?.Value ?? + throw new InvalidDataException( + "The shell integration package manifest has no identity name."); + string publisher = identity.Attribute("Publisher")?.Value ?? + throw new InvalidDataException( + "The shell integration package manifest has no publisher."); + return new ExplorerPackageIdentity(name, publisher); + } + + public static ExplorerPackageIdentity Resolve( + string packagePath, + string looseManifestPath, + string certificatePath, + string expectedPackageName) + { + // Development registration can coexist with the unsigned MSIX used to + // produce it, so its loose manifest is the authoritative local identity. + if (File.Exists(looseManifestPath)) + { + using FileStream manifestStream = File.OpenRead(looseManifestPath); + return ReadManifest(manifestStream); + } + + if (File.Exists(packagePath)) + { + using ZipArchive archive = ZipFile.OpenRead(packagePath); + ZipArchiveEntry manifestEntry = archive.Entries.FirstOrDefault(entry => + string.Equals( + entry.FullName, + "AppxManifest.xml", + StringComparison.OrdinalIgnoreCase)) ?? + throw new InvalidDataException( + "The shell integration package has no AppxManifest.xml file."); + using Stream manifestStream = manifestEntry.Open(); + return ReadManifest(manifestStream); + } + + // A signed installation can outlive the MSIX that originally registered it. + if (File.Exists(certificatePath)) + { + using var certificate = new X509Certificate2(certificatePath); + return FromCertificate(expectedPackageName, certificate); + } + + throw new FileNotFoundException( + "No shell integration package, development manifest, or certificate is available to identify the registered publisher."); + } + + public static ExplorerPackageIdentity FromCertificate( + string expectedPackageName, + X509Certificate2 certificate) => + new(expectedPackageName, certificate.Subject); + + public static ExplorerPackageIdentity? FindRegisteredForExternalPath( + IEnumerable registrations, + string expectedPackageName, + string expectedExternalPath) + { + List identities = registrations + .Where(registration => + string.Equals( + registration.Name, + expectedPackageName, + StringComparison.OrdinalIgnoreCase) && + ExternalPathsEqual( + registration.EffectiveExternalPath, + expectedExternalPath)) + .Select(registration => new ExplorerPackageIdentity( + registration.Name, + registration.Publisher)) + .DistinctBy( + identity => $"{identity.Name}\0{identity.Publisher}", + StringComparer.OrdinalIgnoreCase) + .ToList(); + + return identities.Count switch + { + 0 => null, + 1 => identities[0], + _ => throw new InvalidOperationException( + "Multiple shell integration publishers are registered for this application directory.") + }; + } + + public static bool ExternalPathsEqual(string left, string right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + try + { + string normalizedLeft = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(left)); + string normalizedRight = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(right)); + return string.Equals( + normalizedLeft, + normalizedRight, + StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when ( + ex is ArgumentException or NotSupportedException or PathTooLongException) + { + // A malformed stored path cannot identify a real registration. + // Compare as absent so maintenance reports a status instead of + // escaping the operation boundary. + return false; + } + } +} diff --git a/src/ClipPort/Strings/en-US/Resources.resw b/src/ClipPort/Strings/en-US/Resources.resw index 2266e56..0b3229a 100644 --- a/src/ClipPort/Strings/en-US/Resources.resw +++ b/src/ClipPort/Strings/en-US/Resources.resw @@ -246,6 +246,19 @@ Could not install the context-menu component: {0} Windows did not report a completed installation. The certificate and component status was refreshed. + Uninstall the context-menu component? + ClipPort will no longer appear in the File Explorer context menu. Copy and verification features are unaffected, and the component can be installed again here later. + Uninstall the ClipPort signing certificate? + Only certificates with the exact thumbprint of the certificate in this release directory will be removed. Uninstall the context-menu component first. Windows will request administrator permission when removing a Local Machine certificate. + Uninstall component + Uninstall certificate + The context-menu component was uninstalled and the feature was turned off. + The context-menu component was uninstalled for this installation. Another ClipPort installation still provides the Explorer context menu. + Could not uninstall the context-menu component: {0} + Windows still reports that the component is installed. + The disabled state could not be saved, so the component was not uninstalled: {0} + The ClipPort signing certificate was removed from the certificate stores. + Could not uninstall the signing certificate: {0} Language and files Language Default log and report location @@ -315,9 +328,11 @@ Refresh status . Continue only after the page says the certificate is trusted by the local machine. Open certificate wizard + Uninstall certificate 2. Install the context-menu component After the certificate is trusted correctly, select Install component and wait for the success message. Install component + Uninstall component 3. Turn on the context menu After installation succeeds, turn on Register in the File Explorer context menu above. Open a new File Explorer window, then right-click a folder or folder background to test it. Refresh status diff --git a/src/ClipPort/Strings/lzh/Resources.resw b/src/ClipPort/Strings/lzh/Resources.resw index cf302f0..153df5f 100644 --- a/src/ClipPort/Strings/lzh/Resources.resw +++ b/src/ClipPort/Strings/lzh/Resources.resw @@ -246,6 +246,19 @@ 置右键单组件而败:{0} Windows 未报安置既成。 凭证与组件状态已刷新。 + 卸右键单组件乎? + 卸之,则 ClipPort 不复见于文卷司右键单;复制校验诸能无损,后仍可复置于此。 + 卸 ClipPort 署名凭证乎? + 惟删与今发布目录凭证指纹全同者。请先卸右键单组件;删本地计算机凭证时,Windows 将请管理员之权。 + 卸组件 + 卸凭证 + 右键单组件已卸,其能亦已闭。 + 已卸本机右键单组件;他处 ClipPort 仍供文卷司右键单。 + 卸右键单组件而败:{0} + Windows 犹报组件已置。 + 不能存右键单关闭之态,故未卸组件:{0} + ClipPort 署名凭证已自凭证库除之。 + 卸署名凭证而败:{0} 文辞与文卷 文辞 日志与录默认所存 @@ -315,9 +328,11 @@ 刷新状态 ;俟页面称凭证已置于本地计算机,乃续置组件。 启凭证安置导引 + 卸凭证 二、置右键单组件 凭证既正确安置,点击右侧“置组件”,俟页面称安置成功。 置组件 + 卸组件 三、启右键单 组件既置,启上方“注于文卷司右键单”开关。复启一新文卷司窗口,右击目录或空处以试之。 刷新状态 diff --git a/src/ClipPort/Strings/zh-CN/Resources.resw b/src/ClipPort/Strings/zh-CN/Resources.resw index 957926d..a3a32ad 100644 --- a/src/ClipPort/Strings/zh-CN/Resources.resw +++ b/src/ClipPort/Strings/zh-CN/Resources.resw @@ -246,6 +246,19 @@ 右键菜单组件安装失败:{0} Windows 未报告安装完成。 证书和组件状态已刷新。 + 卸载右键菜单组件? + 卸载后,ClipPort 将不再出现在文件资源管理器右键菜单中,其他复制与校验功能不受影响。之后仍可在此重新安装。 + 卸载 ClipPort 签名证书? + 只会删除与当前发布目录证书指纹完全一致的证书。请先卸载右键菜单组件;删除本地计算机证书时,Windows 会请求管理员权限。 + 卸载组件 + 卸载证书 + 右键菜单组件已卸载,功能开关也已关闭。 + 已卸载当前安装的右键菜单组件;其他 ClipPort 安装仍提供文件资源管理器右键菜单。 + 右键菜单组件卸载失败:{0} + Windows 仍报告该组件已安装。 + 无法保存右键菜单的关闭状态,因此没有卸载组件:{0} + ClipPort 签名证书已从证书存储中删除。 + 签名证书卸载失败:{0} 语言与文件 语言 日志与报告默认保存位置 @@ -315,9 +328,11 @@ 刷新状态 ;确认页面提示证书已安装到本地计算机后,再继续安装组件。 打开证书安装向导 + 卸载证书 2. 安装右键菜单组件 证书安装正确后,点击右侧的“安装组件”,等待页面提示安装成功。 安装组件 + 卸载组件 3. 开启右键菜单 组件安装成功后,打开上方的“注册到文件资源管理器右键菜单”开关。再打开一个新的文件资源管理器窗口,右击文件夹或空白处进行测试。 刷新状态 diff --git a/src/ClipPort/Views/SettingsView.xaml b/src/ClipPort/Views/SettingsView.xaml index f055722..58f6ad7 100644 --- a/src/ClipPort/Views/SettingsView.xaml +++ b/src/ClipPort/Views/SettingsView.xaml @@ -306,11 +306,18 @@ TextWrapping="Wrap" Foreground="{StaticResource MutedTextBrush}" /> -