diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 2e68a19a8f..80e5c8332b 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -216,6 +216,13 @@ jobs: throw "Windows app host was not produced at unigetui_bin/UniGetUI.exe" } + # The elevated policy-write helper is authenticated by exact path at runtime, and it must + # be present here so the code-signing step below signs it and the integrity tree that is + # generated afterwards covers it. + if (-not (Test-Path "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe")) { + throw "Elevated policy helper was not staged at unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe" + } + $MaxShippedPdbSizeBytes = 1MB $PdbsToRemove = Get-ChildItem "unigetui_bin" -Filter "*.pdb" -File -Recurse | Where-Object { $_.Length -gt $MaxShippedPdbSizeBytes @@ -247,6 +254,14 @@ jobs: -CertificateName '${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}' ` -TimestampServer '${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}' + # The helper is the one binary whose signature is checked at runtime by the host before + # it is elevated, so an unsigned helper must fail the release rather than ship. + $HelperPath = Join-Path $PWD "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe" + $HelperSignature = Get-AuthenticodeSignature $HelperPath + if ($HelperSignature.Status -ne "Valid") { + throw "Elevated policy helper is not validly signed (status: $($HelperSignature.Status))." + } + - name: Build installer shell: pwsh run: | diff --git a/UniGetUI.iss b/UniGetUI.iss index 4f83449097..9b2b1edfe8 100644 --- a/UniGetUI.iss +++ b/UniGetUI.iss @@ -130,6 +130,8 @@ begin // Elevator (gsudo cache) and pinget live in {app} and lock their own files. TaskKillWait('UniGetUI Elevator.exe'); TaskKillWait('pinget.exe'); + // The elevated policy helper is short-lived, but it lives in {app} and can hold a file lock. + TaskKillWait('UniGetUI.PolicyElevator.exe'); Sleep(1000); // let the OS release file handles before copying end; @@ -346,3 +348,9 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--migrate-wingetui-to-unigetui"; Filename: {sys}\taskkill.exe; Parameters: "/f /im WingetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillWingetUI" Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUI" Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.Avalonia.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIAvalonia" +Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.PolicyElevator.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIPolicyElevator" + +[UninstallDelete] +; The elevated policy helper is authenticated by exact path, so a leftover copy must never +; survive an uninstall. +Type: files; Name: "{app}\Assets\Utilities\UniGetUI.PolicyElevator.exe" diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 3d348921cd..e1164b4ba8 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -96,6 +96,13 @@ if (-not (Test-Path $WindowsAppHostPath)) { throw "Windows app host was not produced at $WindowsAppHostPath" } +# The elevated policy-write helper is authenticated by exact path at runtime, so a missing or +# misplaced helper must fail the build rather than silently ship an install that cannot elevate. +$PolicyElevatorPath = Join-Path $BinDir "Assets\Utilities\UniGetUI.PolicyElevator.exe" +if (-not (Test-Path $PolicyElevatorPath)) { + throw "Elevated policy helper was not staged at $PolicyElevatorPath" +} + # Keep smaller symbols for useful local crash source information, and prune oversized ones. $MaxShippedPdbSizeBytes = 1MB diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index b2fffa4681..9e6f341043 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -466,7 +466,7 @@ "Loading active package broker policy": "Loading active package broker policy", "Contacting the Devolutions Agent service.": "Contacting the Devolutions Agent service.", "Devolutions Agent is unavailable": "Devolutions Agent is unavailable", - "The package broker could not be reached. Verify that Devolutions Agent is installed and running, then refresh.": "The package broker could not be reached. Verify that Devolutions Agent is installed and running, then refresh.", + "Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh.": "Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh.", "Policy inspection is unsupported": "Policy inspection is unsupported", "The installed Devolutions Agent is reachable but does not support active policy inspection. Update the Agent and try again.": "The installed Devolutions Agent is reachable but does not support active policy inspection. Update the Agent and try again.", "Access to the active policy was denied": "Access to the active policy was denied", @@ -480,7 +480,6 @@ "Connected to Devolutions Agent": "Connected to Devolutions Agent", "The active package broker policy was loaded successfully.": "The active package broker policy was loaded successfully.", "Refresh": "Refresh", - "Refresh active policy": "Refresh active policy", "Policy metadata": "Policy metadata", "Enforcement": "Enforcement", "Rules": "Rules", @@ -1054,5 +1053,353 @@ "The custom operation {0} failed to run": "The custom operation {0} failed to run", "Environment variables use %VARIABLE% syntax.": "Environment variables use %VARIABLE% syntax.", "Environment variables use syntax.": "Environment variables use syntax.", - "Change environment variable syntax": "Change environment variable syntax" + "Change environment variable syntax": "Change environment variable syntax", + "Agent broker unavailable": "Agent broker unavailable", + "Loading policy management state": "Loading policy management state", + "Your organization": "Your organization", + "Policy management is unsupported": "Policy management is unsupported", + "The installed Devolutions Agent is reachable but does not support policy management. Update the Agent and try again.": "The installed Devolutions Agent is reachable but does not support policy management. Update the Agent and try again.", + "Access to policy management was denied": "Access to policy management was denied", + "Devolutions Agent did not authorize UniGetUI to manage the package policy.": "Devolutions Agent did not authorize UniGetUI to manage the package policy.", + "The policy management response is invalid": "The policy management response is invalid", + "Devolutions Agent returned a malformed or incompatible policy management response.": "Devolutions Agent returned a malformed or incompatible policy management response.", + "Policy management is available on Windows only": "Policy management is available on Windows only", + "This page cannot manage the policy file through the Windows Devolutions Agent service on the current platform.": "This page cannot manage the policy file through the Windows Devolutions Agent service on the current platform.", + "The configured policy path is unsafe": "The configured policy path is unsafe", + "Devolutions Agent refused to manage the configured policy path because it is considered unsafe (for example, a path traversal or reparse point).": "Devolutions Agent refused to manage the configured policy path because it is considered unsafe (for example, a path traversal or reparse point).", + "The policy file format is unsupported": "The policy file format is unsupported", + "Devolutions Agent reported that the configured policy file format is not supported for management.": "Devolutions Agent reported that the configured policy file format is not supported for management.", + "The policy file system is unsupported": "The policy file system is unsupported", + "Devolutions Agent reported that the file system hosting the configured policy path is not supported for management.": "Devolutions Agent reported that the file system hosting the configured policy path is not supported for management.", + "The policy management state is unavailable": "The policy management state is unavailable", + "Devolutions Agent supports policy management but could not provide the current state. Review the Agent configuration and try again.": "Devolutions Agent supports policy management but could not provide the current state. Review the Agent configuration and try again.", + "Not applicable": "Not applicable", + "Note": "Note", + "Additional findings were omitted.": "Additional findings were omitted.", + "Policy management is active": "Policy management is active", + "A valid policy file is configured and in effect.": "A valid policy file is configured and in effect.", + "No policy file exists": "No policy file exists", + "No active package policy": "No active package policy", + "Devolutions Agent reports that no policy file exists at the configured path.": "Devolutions Agent reports that no policy file exists at the configured path.", + "Create a new policy file to start enforcing package broker rules.": "Create a new policy file to start enforcing package broker rules.", + "The configured policy file is invalid": "The configured policy file is invalid", + "Review the diagnostics below and repair the policy file.": "Review the diagnostics below and repair the policy file.", + "The policy management state is invalid": "The policy management state is invalid", + "Devolutions Agent returned an unrecognized policy management state.": "Devolutions Agent returned an unrecognized policy management state.", + "{0} ({1})": "{0} ({1})", + "Edit policy '{0}'": "Edit policy '{0}'", + "Replace active policy identity": "Replace active policy identity", + "Create a new package broker policy": "Create a new package broker policy", + "Repair the stored package broker policy": "Repair the stored package broker policy", + "Package broker policy editor": "Package broker policy editor", + "Working…": "Working…", + "Contacting Devolutions Agent.": "Contacting Devolutions Agent.", + "Policy saved": "Policy saved", + "The package broker policy was saved successfully.": "The package broker policy was saved successfully.", + "The document is not valid JSON": "The document is not valid JSON", + "The policy could not be saved": "The policy could not be saved", + "The policy changed since you started editing": "The policy changed since you started editing", + "Review your changes, then choose Overwrite to save anyway.": "Review your changes, then choose Overwrite to save anyway.", + "Validation found errors": "Validation found errors", + "Validation found warnings": "Validation found warnings", + "Review the findings below before saving.": "Review the findings below before saving.", + "The elevation prompt was dismissed. No changes were saved.": "The elevation prompt was dismissed. No changes were saved.", + "The elevated helper could not be started.": "The elevated helper could not be started.", + "The elevated helper could not be authenticated.": "The elevated helper could not be authenticated.", + "Communication with the elevated helper failed.": "Communication with the elevated helper failed.", + "The elevated helper stopped unexpectedly.": "The elevated helper stopped unexpectedly.", + "Devolutions Agent rejected the policy replacement.": "Devolutions Agent rejected the policy replacement.", + "The save failed ({0}).": "The save failed ({0}).", + "The save failed.": "The save failed.", + "Rule: {0}": "Rule: {0}", + "(untitled)": "(untitled)", + "Policy management": "Policy management", + "Refresh policy information": "Refresh policy information", + "State": "State", + "Configured path": "Configured path", + "Path source": "Path source", + "Agent write capability": "Agent write capability", + "Policy changes from this app": "Policy changes from this app", + "Policy change availability reason": "Policy change availability reason", + "Elevation required": "Elevation required", + "Writable": "Writable", + "Read-only": "Read-only", + "Unsupported": "Unsupported", + "Unknown": "Unknown", + "Available": "Available", + "Unavailable": "Unavailable", + "Devolutions Agent does not allow policy changes.": "Devolutions Agent does not allow policy changes.", + "Policy management is disabled in Devolutions Agent.": "Policy management is disabled in Devolutions Agent.", + "No policy path is configured in Devolutions Agent.": "No policy path is configured in Devolutions Agent.", + "Devolutions Agent does not support the configured policy format.": "Devolutions Agent does not support the configured policy format.", + "Devolutions Agent considers the configured policy path unsafe.": "Devolutions Agent considers the configured policy path unsafe.", + "Devolutions Agent does not have permission to change the policy file.": "Devolutions Agent does not have permission to change the policy file.", + "Devolutions Agent does not support the policy file system.": "Devolutions Agent does not support the policy file system.", + "The signed policy write helper is missing. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.": "The signed policy write helper is missing. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.", + "Policy changes are disabled because this UniGetUI installation is not administrator-protected. Reinstall UniGetUI for all users in an administrator-protected location to enable them.": "Policy changes are disabled because this UniGetUI installation is not administrator-protected. Reinstall UniGetUI for all users in an administrator-protected location to enable them.", + "This UniGetUI installation cannot securely launch the policy write helper. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.": "This UniGetUI installation cannot securely launch the policy write helper. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.", + "Edit": "Edit", + "Edit the active policy": "Edit the active policy", + "Create": "Create", + "Create a new policy": "Create a new policy", + "Repair": "Repair", + "Repair the stored policy": "Repair the stored policy", + "Replace identity": "Replace identity", + "Replace the active policy identity": "Replace the active policy identity", + "Diagnostics": "Diagnostics", + "{0}. Location: {1}. JSON pointer: {2}": "{0}. Location: {1}. JSON pointer: {2}", + "The policy format version is unsupported.": "The policy format version is unsupported.", + "Policy document": "Policy document", + "Item {0}": "Item {0}", + "{0} Detail: {1}": "{0} Detail: {1}", + "Schema": "Schema", + "Policy type": "Policy type", + "Policy format version": "Policy format version", + "Metadata": "Metadata", + "ID": "ID", + "Saving with warnings acknowledges the listed risks but does not ignore validation errors.": "Saving with warnings acknowledges the listed risks but does not ignore validation errors.", + "Go to field": "Go to field", + "Go to affected policy field": "Go to affected policy field", + "Go to JSON error": "Go to JSON error", + "Go to raw JSON error": "Go to raw JSON error", + "The $schema field is obsolete. Remove it.": "The $schema field is obsolete. Remove it.", + "PolicyVersion is obsolete. Rename it to PolicyFormatVersion.": "PolicyVersion is obsolete. Rename it to PolicyFormatVersion.", + "The policy draft is missing PolicyFormatVersion.": "The policy draft is missing PolicyFormatVersion.", + "PolicyFormatVersion must be a canonical three-part numeric version such as 1.0.0.": "PolicyFormatVersion must be a canonical three-part numeric version such as 1.0.0.", + "The policy draft uses an unsupported policy format version. This version supports major version 1.": "The policy draft uses an unsupported policy format version. This version supports major version 1.", + "Edit supported policy fields with guided controls. Fixed and Agent-managed values cannot be changed here.": "Edit supported policy fields with guided controls. Fixed and Agent-managed values cannot be changed here.", + "Edit the complete draft JSON. Returning to structured mode requires strict parsing and successful Agent validation.": "Edit the complete draft JSON. Returning to structured mode requires strict parsing and successful Agent validation.", + "Stable authored identity for this policy. Replacing an active identity is a separate operation because it changes which policy is recognized.": "Stable authored identity for this policy. Replacing an active identity is a separate operation because it changes which policy is recognized.", + "Authored organization or person responsible for the policy.": "Authored organization or person responsible for the policy.", + "Software-managed policy document format. It is read-only here and is not the Agent-managed policy revision.": "Software-managed policy document format. It is read-only here and is not the Agent-managed policy revision.", + "Optional authored description. Clear the checkbox to omit this field from the policy.": "Optional authored description. Clear the checkbox to omit this field from the policy.", + "Optional authored HTTP or HTTPS page where users can learn about this policy.": "Optional authored HTTP or HTTPS page where users can learn about this policy.", + "Optional ISO 8601 date and time with an explicit offset. Before this instant, the policy is not yet valid.": "Optional ISO 8601 date and time with an explicit offset. Before this instant, the policy is not yet valid.", + "Optional ISO 8601 date and time with an explicit offset. It must be later than Valid from; after it, the policy is expired.": "Optional ISO 8601 date and time with an explicit offset. It must be later than Valid from; after it, the policy is expired.", + "Decision used when no enabled rule matches. Deny is the safer fail-closed choice; Allow permits unmatched requests.": "Decision used when no enabled rule matches. Deny is the safer fail-closed choice; Allow permits unmatched requests.", + "Fixed evaluation order: lower priority values run first, and Deny wins ties. This software-managed value cannot be changed.": "Fixed evaluation order: lower priority values run first, and Deny wins ties. This software-managed value cannot be changed.", + "When enabled, decisions are logged but not enforced. Use only for evaluation because denied operations may still proceed.": "When enabled, decisions are logged but not enforced. Use only for evaluation because denied operations may still proceed.", + "Add a new authored rule after the existing rules.": "Add a new authored rule after the existing rules.", + "Disabled rules remain in the policy but do not participate in decisions.": "Disabled rules remain in the policy but do not participate in decisions.", + "Copy this rule to create a similar rule. Give the copy a unique rule ID before validation.": "Copy this rule to create a similar rule. Give the copy a unique rule ID before validation.", + "Change this rule's document order. Priority determines evaluation; order mainly affects readability.": "Change this rule's document order. Priority determines evaluation; order mainly affects readability.", + "Remove this rule from the draft. This cannot be undone after the policy is saved.": "Remove this rule from the draft. This cannot be undone after the policy is saved.", + "Unique authored identifier for this rule. Use a stable name so findings and audits can identify it.": "Unique authored identifier for this rule. Use a stable name so findings and audits can identify it.", + "Whole number from 0 through 2147483647. Lower values are evaluated first; Deny wins when priorities tie.": "Whole number from 0 through 2147483647. Lower values are evaluated first; Deny wins when priorities tie.", + "Effect applied when this enabled rule matches: Allow permits the request and Deny blocks it.": "Effect applied when this enabled rule matches: Allow permits the request and Deny blocks it.", + "Optional authored explanation for the rule's decision.": "Optional authored explanation for the rule's decision.", + "Optional package operations matched by this rule. Select none to match every supported operation.": "Optional package operations matched by this rule. Select none to match every supported operation.", + "Optional package managers matched by this rule. Select none to match every supported manager.": "Optional package managers matched by this rule. Select none to match every supported manager.", + "Optional source identifiers, one per line. Leave empty to match packages from any source.": "Optional source identifiers, one per line. Leave empty to match packages from any source.", + "Optional exact package identifiers, one per line. Leave empty to match any identifier.": "Optional exact package identifiers, one per line. Leave empty to match any identifier.", + "Optional package display names, one per line. Agent support may be limited; validate before saving.": "Optional package display names, one per line. Agent support may be limited; validate before saving.", + "Optional exact package versions, one per line. Each value must use the version form accepted by the target package manager.": "Optional exact package versions, one per line. Each value must use the version form accepted by the target package manager.", + "Optionally restrict matching to minimum and maximum versions. Empty bounds are open-ended.": "Optionally restrict matching to minimum and maximum versions. Empty bounds are open-ended.", + "Optional inclusive lower version bound. It must use a valid semantic version accepted by the Agent.": "Optional inclusive lower version bound. It must use a valid semantic version accepted by the Agent.", + "Optional inclusive upper version bound. It must not be lower than the minimum version.": "Optional inclusive upper version bound. It must not be lower than the minimum version.", + "Include prerelease versions when evaluating this version range.": "Include prerelease versions when evaluating this version range.", + "Optional install scopes matched by this rule. Select none to match every scope.": "Optional install scopes matched by this rule. Select none to match every scope.", + "Optional package architectures matched by this rule. Select none to match every architecture.": "Optional package architectures matched by this rule. Select none to match every architecture.", + "Optional requested elevation states matched by this rule. Select none to match elevated and non-elevated requests.": "Optional requested elevation states matched by this rule. Select none to match elevated and non-elevated requests.", + "Select this value to include it in the rule's match criteria. No selected values means any value.": "Select this value to include it in the rule's match criteria. No selected values means any value.", + "Choose Any to omit this match, or Yes/No to require that exact request property.": "Choose Any to omit this match, or Yes/No to require that exact request property.", + "Match whether the operation is interactive. Choose Any to omit this criterion.": "Match whether the operation is interactive. Choose Any to omit this criterion.", + "Match whether the request skips hash verification. This is security-sensitive; choose Any to omit this criterion.": "Match whether the request skips hash verification. This is security-sensitive; choose Any to omit this criterion.", + "Match whether prerelease packages are requested. Choose Any to omit this criterion.": "Match whether prerelease packages are requested. Choose Any to omit this criterion.", + "Match whether custom command-line parameters are present. Choose Any to omit this criterion.": "Match whether custom command-line parameters are present. Choose Any to omit this criterion.", + "Match whether a custom install location is requested. Choose Any to omit this criterion.": "Match whether a custom install location is requested. Choose Any to omit this criterion.", + "Match whether pre-operation or post-operation commands are present. Choose Any to omit this criterion.": "Match whether pre-operation or post-operation commands are present. Choose Any to omit this criterion.", + "Match whether processes may be killed before the operation. Choose Any to omit this criterion.": "Match whether processes may be killed before the operation. Choose Any to omit this criterion.", + "Match whether uninstalling a previous version is requested. Choose Any to omit this criterion.": "Match whether uninstalling a previous version is requested. Choose Any to omit this criterion.", + "Optional limits applied after a rule matches. Package dependencies, agreements, reboot behavior, and ordinary hash verification remain controlled by the package manager unless represented below.": "Optional limits applied after a rule matches. Package dependencies, agreements, reboot behavior, and ordinary hash verification remain controlled by the package manager unless represented below.", + "Allow an interactive package-manager operation after this rule matches.": "Allow an interactive package-manager operation after this rule matches.", + "Allow bypassing package hash verification. This weakens integrity protection and should be narrowly scoped.": "Allow bypassing package hash verification. This weakens integrity protection and should be narrowly scoped.", + "Allow installing prerelease package versions after this rule matches.": "Allow installing prerelease package versions after this rule matches.", + "Allow a caller-provided install location. Use allowed patterns to restrict where packages may be written.": "Allow a caller-provided install location. Use allowed patterns to restrict where packages may be written.", + "Allowed custom install-location wildcard patterns, one per line. Empty means no pattern restriction when custom locations are allowed.": "Allowed custom install-location wildcard patterns, one per line. Empty means no pattern restriction when custom locations are allowed.", + "Allow caller-provided package-manager arguments. Restrict them with exact values, allowed patterns, or denied values.": "Allow caller-provided package-manager arguments. Restrict them with exact values, allowed patterns, or denied values.", + "Exact custom parameters allowed by this rule, one per line.": "Exact custom parameters allowed by this rule, one per line.", + "Wildcard patterns for custom parameters allowed by this rule, one per line.": "Wildcard patterns for custom parameters allowed by this rule, one per line.", + "Custom parameters explicitly denied by this rule, one per line. Denials take precedence over allowances.": "Custom parameters explicitly denied by this rule, one per line. Denials take precedence over allowances.", + "Allow caller-provided commands before or after package operations. This is dangerous and should be narrowly scoped.": "Allow caller-provided commands before or after package operations. This is dangerous and should be narrowly scoped.", + "Allow terminating processes before the package operation.": "Allow terminating processes before the package operation.", + "Allow uninstalling an existing package version before installing another version.": "Allow uninstalling an existing package version before installing another version.", + "Allow the operation to upgrade an already installed package.": "Allow the operation to upgrade an already installed package.", + "Send the current draft to Devolutions Agent for authoritative validation without saving it.": "Send the current draft to Devolutions Agent for authoritative validation without saving it.", + "Validate and save the draft. Warnings require acknowledgement, and policy changes may require elevation.": "Validate and save the draft. Warnings require acknowledgement, and policy changes may require elevation.", + "Replace a policy that changed after editing began. Review the conflict carefully because this discards the newer external version.": "Replace a policy that changed after editing began. Review the conflict carefully because this discards the newer external version.", + "Agent validation findings are sanitized and bounded. Use Go to field to navigate to the affected structured control when available.": "Agent validation findings are sanitized and bounded. Use Go to field to navigate to the affected structured control when available.", + "Navigate to and focus the structured field associated with this validation finding.": "Navigate to and focus the structured field associated with this validation finding.", + "Focus the raw JSON editor at the document that could not be parsed or represented.": "Focus the raw JSON editor at the document that could not be parsed or represented.", + "Read-only canonical JSON returned for the active policy. Copy it for diagnostics or review.": "Read-only canonical JSON returned for the active policy. Copy it for diagnostics or review.", + "Copy the complete canonical active-policy JSON to the clipboard.": "Copy the complete canonical active-policy JSON to the clipboard.", + "Refresh management state and active-policy inspection together.": "Refresh management state and active-policy inspection together.", + "Authoritative write capability reported by Devolutions Agent.": "Authoritative write capability reported by Devolutions Agent.", + "Whether this UniGetUI installation can perform policy changes, considering both Agent capability and trusted-helper availability.": "Whether this UniGetUI installation can perform policy changes, considering both Agent capability and trusted-helper availability.", + "Why policy changes from this app are unavailable. Agent restrictions take precedence over local helper status.": "Why policy changes from this app are unavailable. Agent restrictions take precedence over local helper status.", + "Whether Devolutions Agent reports that policy changes require administrator elevation.": "Whether Devolutions Agent reports that policy changes require administrator elevation.", + "Open the active policy as a draft while preserving its identity and compatible policy format version.": "Open the active policy as a draft while preserving its identity and compatible policy format version.", + "Create a new policy draft using the current software-managed policy format version.": "Create a new policy draft using the current software-managed policy format version.", + "Replace an invalid stored policy with a new valid draft after review and validation.": "Replace an invalid stored policy with a new valid draft after review and validation.", + "Create a replacement policy with a new policy ID. This intentionally changes the active policy identity and requires confirmation.": "Create a replacement policy with a new policy ID. This intentionally changes the active policy identity and requires confirmation.", + "Authoritative policy-file state reported by Devolutions Agent.": "Authoritative policy-file state reported by Devolutions Agent.", + "Agent-managed policy file path. UniGetUI displays this path but does not author it here.": "Agent-managed policy file path. UniGetUI displays this path but does not author it here.", + "How Devolutions Agent selected the policy path, such as its default or configured path.": "How Devolutions Agent selected the policy path, such as its default or configured path.", + "Environment variables in custom install options": "Environment variables in custom install options", + "Use %VARIABLE% syntax for environment variables in custom install arguments and locations": "Use %VARIABLE% syntax for environment variables in custom install arguments and locations", + "By default UniGetUI expands tokens, which cannot appear in a real path. Enabling this uses the Windows %VARIABLE% syntax instead, which may unexpectedly expand folder names that contain a variable name such as %TEMP%.": "By default UniGetUI expands tokens, which cannot appear in a real path. Enabling this uses the Windows %VARIABLE% syntax instead, which may unexpectedly expand folder names that contain a variable name such as %TEMP%.", + "Save policy with warnings?": "Save policy with warnings?", + "Save anyway": "Save anyway", + "Replace the active policy?": "Replace the active policy?", + "Replace": "Replace", + "Create a new policy?": "Create a new policy?", + "Repair the stored policy?": "Repair the stored policy?", + "Overwrite": "Overwrite", + "Discard unsaved changes?": "Discard unsaved changes?", + "Confirm": "Confirm", + "Validation reported {0} warning(s) for policy '{1}'. Do you want to save it anyway?": "Validation reported {0} warning(s) for policy '{1}'. Do you want to save it anyway?", + "This will replace the active policy '{0}' with a new policy '{1}'. This cannot be undone.": "This will replace the active policy '{0}' with a new policy '{1}'. This cannot be undone.", + "This will create a new package broker policy '{0}'.": "This will create a new package broker policy '{0}'.", + "The stored policy file is invalid and will be replaced with '{0}'.": "The stored policy file is invalid and will be replaced with '{0}'.", + "The policy '{0}' was modified elsewhere since you started editing. Overwrite it with your changes?": "The policy '{0}' was modified elsewhere since you started editing. Overwrite it with your changes?", + "You have unsaved changes to policy '{0}'. Discard them?": "You have unsaved changes to policy '{0}'. Discard them?", + "Do you want to continue?": "Do you want to continue?", + "Structured": "Structured", + "Switch to the structured editor": "Switch to the structured editor", + "Raw JSON": "Raw JSON", + "Switch to the raw JSON editor": "Switch to the raw JSON editor", + "Valid from (optional ISO-8601)": "Valid from (optional ISO-8601)", + "Valid until (optional ISO-8601)": "Valid until (optional ISO-8601)", + "Rules are always evaluated by priority; ties are broken toward Deny. This precedence is fixed and cannot be changed.": "Rules are always evaluated by priority; ties are broken toward Deny. This precedence is fixed and cannot be changed.", + "Add rule": "Add rule", + "This policy has no rules yet.": "This policy has no rules yet.", + "Rule enabled": "Rule enabled", + "Duplicate": "Duplicate", + "Duplicate rule": "Duplicate rule", + "Move up": "Move up", + "Move rule up": "Move rule up", + "Move down": "Move down", + "Move rule down": "Move rule down", + "Delete": "Delete", + "Delete rule": "Delete rule", + "Operations (optional)": "Operations (optional)", + "Package managers (optional)": "Package managers (optional)", + "Scopes (optional)": "Scopes (optional)", + "Architectures (optional)": "Architectures (optional)", + "Elevation (optional)": "Elevation (optional)", + "Sources (one per line; optional)": "Sources (one per line; optional)", + "Package identifiers (one per line; optional)": "Package identifiers (one per line; optional)", + "Package names (one per line; optional)": "Package names (one per line; optional)", + "Versions (one per line; optional)": "Versions (one per line; optional)", + "Restrict to a version range": "Restrict to a version range", + "Minimum version": "Minimum version", + "Maximum version": "Maximum version", + "Include prerelease versions": "Include prerelease versions", + "This rule defines constraints": "This rule defines constraints", + "Allow interactive installs": "Allow interactive installs", + "Allow skipping the hash check": "Allow skipping the hash check", + "Allow prerelease packages": "Allow prerelease packages", + "Allow a custom install location": "Allow a custom install location", + "Allow uninstalling a previous version": "Allow uninstalling a previous version", + "Allow upgrades": "Allow upgrades", + "Edit the canonical policy document as JSON. Switching back to the structured editor requires the document to parse and pass validation.": "Edit the canonical policy document as JSON. Switching back to the structured editor requires the document to parse and pass validation.", + "Raw policy JSON": "Raw policy JSON", + "Validation findings": "Validation findings", + "Validate": "Validate", + "Validate policy": "Validate policy", + "Overwrite the policy that changed elsewhere": "Overwrite the policy that changed elsewhere", + "Save policy": "Save policy", + "Close policy editor": "Close policy editor", + "No reason provided": "No reason provided", + "Operation denied by policy": "Operation denied by policy", + "Operation failed via broker": "Operation failed via broker", + "The broker accepted the request but did not report an operation to track.": "The broker accepted the request but did not report an operation to track.", + "The operation did not finish within the allotted time. It may still be running on the agent.": "The operation did not finish within the allotted time. It may still be running on the agent.", + "Operation denied or failed via broker": "Operation denied or failed via broker", + "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.": "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.", + "A boolean match must be omitted, true, or false; mixed arrays are invalid.": "A boolean match must be omitted, true, or false; mixed arrays are invalid.", + "A policy field has an invalid value.": "A policy field has an invalid value.", + "A policy field has the wrong value type.": "A policy field has the wrong value type.", + "A wildcard pattern is invalid.": "A wildcard pattern is invalid.", + "An enabled Allow rule permits a sensitive option.": "An enabled Allow rule permits a sensitive option.", + "An enabled Allow rule permits custom command-line parameters.": "An enabled Allow rule permits custom command-line parameters.", + "An enabled Allow rule permits custom install locations.": "An enabled Allow rule permits custom install locations.", + "An enabled Allow rule permits killing processes before an operation.": "An enabled Allow rule permits killing processes before an operation.", + "An enabled Allow rule permits pre-operation or post-operation commands.": "An enabled Allow rule permits pre-operation or post-operation commands.", + "An enabled Allow rule permits prerelease package versions.": "An enabled Allow rule permits prerelease package versions.", + "An enabled Allow rule permits skipping package hash verification.": "An enabled Allow rule permits skipping package hash verification.", + "An enabled Allow rule permits uninstalling a previous version.": "An enabled Allow rule permits uninstalling a previous version.", + "Audit mode is enabled; decisions are logged but not enforced.": "Audit mode is enabled; decisions are logged but not enforced.", + "Configured policy path": "Configured policy path", + "Correct the highlighted fields": "Correct the highlighted fields", + "Devolutions Agent reported an unrecognized policy finding.": "Devolutions Agent reported an unrecognized policy finding.", + "Enter a valid ISO 8601 date and time.": "Enter a valid ISO 8601 date and time.", + "Policy management state": "Policy management state", + "Policy operation in progress": "Policy operation in progress", + "Policy path source": "Policy path source", + "Policy read-only reason": "Policy read-only reason", + "Policy write capability": "Policy write capability", + "Policy write elevation requirement": "Policy write elevation requirement", + "Raw JSON syntax error": "Raw JSON syntax error", + "Restrictions: {0}": "Restrictions: {0}", + "Rule IDs must be unique.": "Rule IDs must be unique.", + "The default decision is Allow; requests matching no rule are permitted.": "The default decision is Allow; requests matching no rule are permitted.", + "The policy draft contains an unknown field.": "The policy draft contains an unknown field.", + "The policy draft does not match the required JSON schema.": "The policy draft does not match the required JSON schema.", + "The policy draft is missing a required field.": "The policy draft is missing a required field.", + "The policy type is unsupported.": "The policy type is unsupported.", + "The policy validity interval is invalid.": "The policy validity interval is invalid.", + "The rule contains contradictory constraints.": "The rule contains contradictory constraints.", + "The version range does not restrict any versions.": "The version range does not restrict any versions.", + "The version range is invalid.": "The version range is invalid.", + "Please wait for the current policy operation to finish before closing.": "Please wait for the current policy operation to finish before closing.", + "The current policy operation could not be canceled in time. Please wait, then try closing again.": "The current policy operation could not be canceled in time. Please wait, then try closing again.", + "The active policy '{0}' changed since editing began. Overwrite that exact current version with your changes?": "The active policy '{0}' changed since editing began. Overwrite that exact current version with your changes?", + "The policy store now contains active policy '{0}'. Replace it with the different policy identity '{1}'?": "The policy store now contains active policy '{0}'. Replace it with the different policy identity '{1}'?", + "The policy store is now missing. Create policy '{0}' against that exact current state?": "The policy store is now missing. Create policy '{0}' against that exact current state?", + "The policy store is now invalid. Replace it with repaired policy '{0}' against that exact current state?": "The policy store is now invalid. Replace it with repaired policy '{0}' against that exact current state?", + "The policy was saved, but newer draft changes remain unsaved.": "The policy was saved, but newer draft changes remain unsaved.", + "Policy saved; newer changes remain": "Policy saved; newer changes remain", + "{0} additional validation finding(s) were omitted.": "{0} additional validation finding(s) were omitted.", + "{0}. Location: {1}": "{0}. Location: {1}", + "Policy saved, then replaced again": "Policy saved, then replaced again", + "The policy was saved, but another writer replaced it before management state was refreshed.": "The policy was saved, but another writer replaced it before management state was refreshed.", + "The policy write result is unknown. Refresh policy management state before retrying.": "The policy write result is unknown. Refresh policy management state before retrying.", + "The document is not a valid policy draft": "The document is not a valid policy draft", + "The document is empty.": "The document is empty.", + "The JSON syntax is invalid.": "The JSON syntax is invalid.", + "The policy draft uses an unsupported schema.": "The policy draft uses an unsupported schema.", + "The policy draft uses an unsupported policy type.": "The policy draft uses an unsupported policy type.", + "The policy draft is missing the Enforcement object.": "The policy draft is missing the Enforcement object.", + "The policy draft uses an unsupported rule precedence.": "The policy draft uses an unsupported rule precedence.", + "The policy draft is missing the Metadata object.": "The policy draft is missing the Metadata object.", + "The document does not match the policy draft format.": "The document does not match the policy draft format.", + "Enter a whole number from 0 through 2147483647.": "Enter a whole number from 0 through 2147483647.", + "Allowed custom locations": "Allowed custom locations", + "Allowed hash-check skipping": "Allowed hash-check skipping", + "Allowed pre/post commands": "Allowed pre/post commands", + "AdministratorRequired": "Administrator required", + "BadRequest": "Bad request", + "BrokerPaused": "Broker paused", + "Conflict": "Conflict", + "Forbidden": "Forbidden", + "Info": "Information", + "InternalError": "Internal error", + "InvalidPolicy": "Invalid policy", + "MalformedDraft": "Malformed draft", + "NotFound": "Not found", + "PayloadTooLarge": "Payload too large", + "PolicyActivationFailed": "Policy activation failed", + "PolicyPersistenceFailed": "Policy persistence failed", + "StalePolicyStoreToken": "The policy changed since editing began", + "Timeout": "Timed out", + "Unauthenticated": "Authentication required", + "Unauthorized": "Unauthorized", + "UnsafePolicyPath": "Unsafe policy path", + "UnsupportedEndpoint": "Unsupported endpoint", + "UnsupportedMediaType": "Unsupported media type", + "UnsupportedPolicyFilesystem": "Unsupported policy filesystem", + "UnsupportedPolicyFormat": "Unsupported policy format", + "ValidationFailed": "Validation failed", + "WarningConfirmationRequired": "Warning confirmation required" } diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperStageTimeouts.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperStageTimeouts.cs new file mode 100644 index 0000000000..ec936506c9 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperStageTimeouts.cs @@ -0,0 +1,32 @@ +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +internal sealed class PolicyElevationHelperStageTimeouts : IDisposable +{ + private readonly TimeSpan _exchangeTimeout; + private CancellationTokenSource? _stage; + private bool _exchangeStarted; + + public PolicyElevationHelperStageTimeouts(TimeSpan connectTimeout, TimeSpan exchangeTimeout) + { + _exchangeTimeout = exchangeTimeout; + _stage = new CancellationTokenSource(connectTimeout); + } + + public CancellationToken Token => + _stage?.Token ?? throw new ObjectDisposedException(nameof(PolicyElevationHelperStageTimeouts)); + + public void BeginExchange() + { + ObjectDisposedException.ThrowIf(_stage is null, this); + if (_exchangeStarted) + throw new InvalidOperationException("The helper exchange timeout has already started."); + + _exchangeStarted = true; + CancellationTokenSource connectStage = _stage; + _stage = new CancellationTokenSource(_exchangeTimeout); + connectStage.Dispose(); + } + + public void Dispose() => + Interlocked.Exchange(ref _stage, null)?.Dispose(); +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperSynchronousStageRunner.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperSynchronousStageRunner.cs new file mode 100644 index 0000000000..12c4c805f6 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperSynchronousStageRunner.cs @@ -0,0 +1,61 @@ +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +internal readonly record struct PolicyElevationHelperSynchronousStageResult( + bool Completed, + T Value) +{ + public static PolicyElevationHelperSynchronousStageResult TimedOut => + new(false, default!); +} + +internal static class PolicyElevationHelperSynchronousStageRunner +{ + public static async Task> RunAsync( + Func operation, + CancellationToken cancellationToken, + Action? disposeAbandonedResult = null, + Action? cleanupAfterAbandonedWork = null) + { + cancellationToken.ThrowIfCancellationRequested(); + Task worker = Task.Run( + () => + { + cancellationToken.ThrowIfCancellationRequested(); + return operation(); + }, + CancellationToken.None); + + try + { + T result = await worker.WaitAsync(cancellationToken).ConfigureAwait(false); + return new(true, result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Task cleanup = worker.ContinueWith( + completed => + { + try + { + if (completed.Status == TaskStatus.RanToCompletion) + disposeAbandonedResult?.Invoke(completed.Result); + else + _ = completed.Exception; + } + finally + { + cleanupAfterAbandonedWork?.Invoke(); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + _ = cleanup.ContinueWith( + static faulted => _ = faulted.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return PolicyElevationHelperSynchronousStageResult.TimedOut; + } + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs new file mode 100644 index 0000000000..bb1cc94083 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Client; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation.Interop; + +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +/// +/// Turns the single broker replacement call into the bounded response frame contract. +/// +internal static class PolicyReplacementExecutor +{ + public static async Task ExecuteAsync( + PolicyElevationRequestMessage request, + string effectiveUser, + CancellationToken cancellationToken) + { + var response = new PolicyElevationResponseMessage + { + ProtocolVersion = PolicyElevationProtocol.Version, + RequestId = request.RequestId, + }; + + try + { + using var client = new BrokerClient(CreateClientOptions(effectiveUser)); + + PolicyReplacementResponse replacement = + await PolicyElevationReplacementDispatcher.DispatchAsync( + request, + client.ReplacePolicy, + cancellationToken).ConfigureAwait(false); + + response.Disposition = PolicyElevationDisposition.Committed; + response.CommittedStoreToken = replacement.Management.StoreToken; + PolicyElevationFrame.ValidateResponse(response); + return response; + } + catch (BrokerClientException ex) + { + response.Disposition = GetFailureDisposition(ex); + response.BrokerStatusCode = ex.StatusCode; + response.BrokerErrorCode = Truncate( + ex.BrokerError?.Code.ToString() ?? ex.Kind.ToString(), + PolicyElevationProtocol.MaxBrokerErrorCodeCharacters); + if (response.Disposition == PolicyElevationDisposition.Rejected + && ex.BrokerError is + { + Code: ErrorCode.StalePolicyStoreToken, + Management: not null, + } stale) + { + response.ConflictStoreToken = stale.Management.StoreToken; + response.ConflictState = stale.Management.State switch + { + PolicyManagementState.Active => PolicyElevationManagementState.Active, + PolicyManagementState.Missing => PolicyElevationManagementState.Missing, + PolicyManagementState.Invalid => PolicyElevationManagementState.Invalid, + _ => throw new InvalidDataException("The stale response carried an invalid management state."), + }; + response.ConflictPolicyId = stale.Management.Policy?.Metadata.Id; + } + return response; + } + catch (OperationCanceledException) + { + response.Disposition = PolicyElevationDisposition.Unknown; + response.BrokerErrorCode = BrokerClientErrorKind.Timeout.ToString(); + return response; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException) + { + response.Disposition = PolicyElevationDisposition.Unknown; + response.BrokerErrorCode = BrokerClientErrorKind.InvalidResponse.ToString(); + return response; + } + } + + internal static PolicyElevationDisposition GetFailureDisposition(BrokerClientException exception) + { + ArgumentNullException.ThrowIfNull(exception); + return exception.Kind is + BrokerClientErrorKind.BrokerUnavailable + or BrokerClientErrorKind.Timeout + or BrokerClientErrorKind.EmptyResponse + or BrokerClientErrorKind.InvalidResponse + || (exception.Kind == BrokerClientErrorKind.BrokerError + && exception.BrokerError is null) + ? PolicyElevationDisposition.Unknown + : PolicyElevationDisposition.Rejected; + } + + internal static BrokerClientOptions CreateClientOptions(string effectiveUser) + { + ArgumentException.ThrowIfNullOrWhiteSpace(effectiveUser); + if (!WindowsProcessInspector.IsValidEffectiveUser(effectiveUser)) + { + throw new ArgumentException("The effective user is not a bounded Windows account name.", nameof(effectiveUser)); + } + + return new BrokerClientOptions + { + RequestedElevation = Elevation.Elevated, + EffectiveUser = effectiveUser, + ClientExecutablePath = Environment.ProcessPath, + ClientVersion = typeof(PolicyReplacementExecutor).Assembly.GetName().Version?.ToString(), + }; + } + + private static string? Truncate(string? value, int maxCharacters) + { + if (value is null) + { + return null; + } + + return value.Length <= maxCharacters ? value : value[..maxCharacters]; + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs new file mode 100644 index 0000000000..dc8025ebda --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs @@ -0,0 +1,395 @@ +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation.Interop; + +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +/// +/// The elevated policy-write helper. +/// +/// +/// +/// This process is started by a non-elevated UniGetUI through ShellExecuteEx with the +/// runas verb, so it runs with a full administrator token. Its command line carries routing +/// information only — a pipe name, the caller's process id, the caller's process creation time and +/// the logon session. The policy draft, the store token, the validation receipt and every other +/// piece of request state travel exclusively over the authenticated pipe, and no temporary file is +/// ever used. +/// +/// +/// The helper handles exactly one connection, reads exactly one request, writes exactly one +/// response and exits. It performs its half of the mutual authentication before reading a single +/// byte of payload, and it connects with an anonymous impersonation level so a rogue pipe cannot +/// borrow its elevated token. +/// +/// +internal static class Program +{ + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return PolicyElevationProtocol.ExitInvalidArguments; + } + + if (!PolicyElevationLaunchArguments.TryParse(args, out PolicyElevationLaunchArguments? launch, out _)) + { + return PolicyElevationProtocol.ExitInvalidArguments; + } + + using var stageTimeouts = new PolicyElevationHelperStageTimeouts( + PolicyElevationProtocol.ConnectTimeout, + PolicyElevationProtocol.ExchangeTimeout); + + try + { + return await RunAsync(launch, stageTimeouts).ConfigureAwait(false); + } + catch (PolicyElevationFrameException) + { + return PolicyElevationProtocol.ExitProtocolError; + } + catch (OperationCanceledException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + catch (IOException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + catch (Exception) + { + return PolicyElevationProtocol.ExitUnexpectedFailure; + } + } + + private static async Task RunAsync( + PolicyElevationLaunchArguments launch, + PolicyElevationHelperStageTimeouts stageTimeouts) + { + AuthenticatedHostContext? authenticatedHost = null; + NamedPipeClientStream? pipe = null; + try + { + PolicyElevationHelperSynchronousStageResult preparation = + await PolicyElevationHelperSynchronousStageRunner.RunAsync( + () => TryPrepareAuthenticatedHost(launch), + stageTimeouts.Token, + static abandoned => abandoned?.Dispose()).ConfigureAwait(false); + if (!preparation.Completed) + return PolicyElevationProtocol.ExitConnectFailed; + + authenticatedHost = preparation.Value; + if (authenticatedHost is null) + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + + pipe = new NamedPipeClientStream( + ".", + launch.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous); + + await pipe.ConnectAsync( + (int)PolicyElevationProtocol.ConnectTimeout.TotalMilliseconds, + stageTimeouts.Token) + .ConfigureAwait(false); + + AuthenticatedHostContext pipeAuthenticationHost = authenticatedHost; + NamedPipeClientStream authenticatedPipe = pipe; + PolicyElevationHelperSynchronousStageResult pipeAuthentication = + await PolicyElevationHelperSynchronousStageRunner.RunAsync( + () => AuthenticateConnectedPipe(pipeAuthenticationHost, authenticatedPipe), + stageTimeouts.Token, + cleanupAfterAbandonedWork: () => + { + authenticatedPipe.Dispose(); + pipeAuthenticationHost.Dispose(); + }).ConfigureAwait(false); + if (!pipeAuthentication.Completed) + { + pipe = null; + authenticatedHost = null; + return PolicyElevationProtocol.ExitConnectFailed; + } + + if (pipeAuthentication.Value != PolicyElevationProtocol.ExitSuccess) + return pipeAuthentication.Value; + + AuthenticatedHostContext identityHost = authenticatedHost; + NamedPipeClientStream identityPipe = pipe; + PolicyElevationHelperSynchronousStageResult identityResolution = + await PolicyElevationHelperSynchronousStageRunner.RunAsync( + () => ResolveInitiatingUser(identityHost), + stageTimeouts.Token, + cleanupAfterAbandonedWork: () => + { + identityPipe.Dispose(); + identityHost.Dispose(); + }).ConfigureAwait(false); + if (!identityResolution.Completed) + { + pipe = null; + authenticatedHost = null; + return PolicyElevationProtocol.ExitConnectFailed; + } + + InitiatingUserResult identity = identityResolution.Value; + if (identity.ExitCode != PolicyElevationProtocol.ExitSuccess + || identity.EffectiveUser is null) + { + return identity.ExitCode; + } + + PolicyElevationRequestMessage request = + await PolicyElevationFrame.ReadRequestAsync(pipe, stageTimeouts.Token).ConfigureAwait(false); + stageTimeouts.Token.ThrowIfCancellationRequested(); + stageTimeouts.BeginExchange(); + + using var brokerCancellation = + CancellationTokenSource.CreateLinkedTokenSource(stageTimeouts.Token); + using var disconnectMonitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(stageTimeouts.Token); + Task disconnectMonitor = MonitorHostDisconnectAsync( + pipe, + brokerCancellation, + disconnectMonitorCancellation.Token); + + PolicyElevationResponseMessage response; + try + { + response = await PolicyReplacementExecutor + .ExecuteAsync(request, identity.EffectiveUser, brokerCancellation.Token) + .ConfigureAwait(false); + } + finally + { + await disconnectMonitorCancellation.CancelAsync().ConfigureAwait(false); + try + { + await disconnectMonitor.ConfigureAwait(false); + } + catch (OperationCanceledException) when (disconnectMonitorCancellation.IsCancellationRequested) + { + } + } + + // WriteResponseAsync completes only once the whole frame has been handed to the pipe and + // flushed, under the same bounded, cancellable token as every other stage. Closing the + // handle afterwards is enough: a synchronous drain would block on the reader with no + // timeout and no cancellation, which is exactly the unbounded hang this design forbids. + using var responseWrite = + new CancellationTokenSource(PolicyElevationProtocol.ResponseWriteTimeout); + await PolicyElevationFrame.WriteResponseAsync(pipe, response, responseWrite.Token).ConfigureAwait(false); + + return PolicyElevationProtocol.ExitSuccess; + } + catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + finally + { + pipe?.Dispose(); + authenticatedHost?.Dispose(); + } + } + + private static AuthenticatedHostContext? TryPrepareAuthenticatedHost( + PolicyElevationLaunchArguments launch) + { + IPolicyElevationTrustVerifier trustVerifier = new WindowsAuthenticodeTrustVerifier(); + if (!TryDescribePackagedLayout( + out string? installRoot, + out string? hostPath, + out string? selfPath, + out PolicyElevationLocationVerification? verification) + || installRoot is null || hostPath is null || selfPath is null || verification is null) + { + verification?.Dispose(); + return null; + } + + SafeProcessHandle host = PolicyElevationNative.OpenProcess( + PolicyElevationNative.ProcessQueryLimitedInformation | PolicyElevationNative.Synchronize, + false, + unchecked((uint)launch.ParentProcessId)); + if (host.IsInvalid) + { + host.Dispose(); + verification.Dispose(); + return null; + } + + var expectation = new PolicyElevationPeerExpectation( + hostPath, + installRoot, + unchecked((uint)launch.ParentProcessId), + launch.ParentCreationTimeUtcTicks, + launch.SessionId) + { + RequireElevatedAdministrator = false, + Verification = verification, + }; + + if (!WindowsPeerAuthenticator + .Authenticate( + host.DangerousGetHandle(), + expectation.ExpectedProcessId, + expectation, + trustVerifier, + selfPath) + .IsAuthenticated) + { + host.Dispose(); + verification.Dispose(); + return null; + } + + return new AuthenticatedHostContext( + verification, + host, + expectation, + trustVerifier, + selfPath); + } + + private static int AuthenticateConnectedPipe( + AuthenticatedHostContext authenticatedHost, + NamedPipeClientStream pipe) + { + if (!PolicyElevationNative.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId)) + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + + return WindowsPeerAuthenticator + .Authenticate( + authenticatedHost.Host.DangerousGetHandle(), + serverProcessId, + authenticatedHost.Expectation, + authenticatedHost.TrustVerifier, + authenticatedHost.SelfPath) + .IsAuthenticated + ? PolicyElevationProtocol.ExitSuccess + : PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + private static InitiatingUserResult ResolveInitiatingUser( + AuthenticatedHostContext authenticatedHost) + { + int exitCode = PolicyElevationInitiatingUserResolver.Resolve( + authenticatedHost.Host.DangerousGetHandle(), + out string? effectiveUser); + return new InitiatingUserResult(exitCode, effectiveUser); + } + + private static async Task MonitorHostDisconnectAsync( + NamedPipeClientStream pipe, + CancellationTokenSource brokerCancellation, + CancellationToken cancellationToken) + { + byte[] unexpectedData = new byte[1]; + try + { + int read = await pipe.ReadAsync(unexpectedData, cancellationToken).ConfigureAwait(false); + if (!cancellationToken.IsCancellationRequested) + { + await brokerCancellation.CancelAsync().ConfigureAwait(false); + } + } + catch (IOException) + { + await brokerCancellation.CancelAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + /// + /// Confirms this process really is the packaged helper, and derives both the install root the + /// host must also live in and this process' own canonical image, which the mutual signer + /// binding needs. The returned verification holds kernel handles to every verified object and + /// must stay alive for the whole exchange. + /// + private static bool TryDescribePackagedLayout( + out string? installRoot, + out string? hostPath, + out string? selfImagePath, + out PolicyElevationLocationVerification? verification) + { + installRoot = null; + hostPath = null; + selfImagePath = null; + verification = null; + + string? selfPath = WindowsProcessInspector.TryGetCurrentProcessCanonicalPath(); + if (selfPath is null + || !PolicyElevationPaths.TryGetInstallRootFromHelperPath(selfPath, out string? root) + || root is null) + { + return false; + } + + string? canonicalHostPath = WindowsProcessInspector.TryGetCanonicalPath( + PolicyElevationPaths.GetHostPath(root)); + + if (canonicalHostPath is null) + { + return false; + } + + // Always handle-verified: this process is about to perform a machine-wide policy write, so + // the packaged layout it was launched from has to be provably administrator-protected. + PolicyElevationLocationVerification verified = + new WindowsProtectedLocationVerifier().Verify(root, selfPath, canonicalHostPath); + + if (!verified.IsProtected + || !WindowsProcessInspector.PathsAreEqual(verified.CanonicalHelperPath, selfPath) + || !WindowsProcessInspector.PathsAreEqual(verified.CanonicalHostPath, canonicalHostPath) + || !WindowsProcessInspector.PathsAreEqual(verified.CanonicalInstallRoot, root)) + { + verified.Dispose(); + return false; + } + + verification = verified; + installRoot = root; + hostPath = canonicalHostPath; + selfImagePath = selfPath; + return true; + } + + private readonly record struct InitiatingUserResult(int ExitCode, string? EffectiveUser); + + private sealed class AuthenticatedHostContext : IDisposable + { + public AuthenticatedHostContext( + PolicyElevationLocationVerification layout, + SafeProcessHandle host, + PolicyElevationPeerExpectation expectation, + IPolicyElevationTrustVerifier trustVerifier, + string selfPath) + { + Layout = layout; + Host = host; + Expectation = expectation; + TrustVerifier = trustVerifier; + SelfPath = selfPath; + } + + public PolicyElevationLocationVerification Layout { get; } + public SafeProcessHandle Host { get; } + public PolicyElevationPeerExpectation Expectation { get; } + public IPolicyElevationTrustVerifier TrustVerifier { get; } + public string SelfPath { get; } + + public void Dispose() + { + Host.Dispose(); + Layout.Dispose(); + } + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj b/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj new file mode 100644 index 0000000000..e885382558 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj @@ -0,0 +1,52 @@ + + + + + + $(WindowsTargetFramework) + Exe + UniGetUI.PolicyElevator + UniGetUI.AgentPolicy.ElevatedHelper + app.manifest + win-x64;win-arm64 + true + true + true + en + true + true + false + true + full + false + true + true + false + false + false + false + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/app.manifest b/src/UniGetUI.AgentPolicy.ElevatedHelper/app.manifest new file mode 100644 index 0000000000..8a0c06b8f9 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/app.manifest @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + true + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs index def6de74cd..f541199b4c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs @@ -8,17 +8,20 @@ internal static class AppRestartHelper { private const string LauncherExecutableName = "UniGetUI.exe"; - public static void Restart() + public static void Restart() => _ = RestartAsync(); + + private static async Task RestartAsync() { string executablePath = ResolveRestartExecutablePath(AppContext.BaseDirectory); - CoreTools.ScheduleRelaunchAfterExit(executablePath); if (MainWindow.Instance is { } mainWindow) { - mainWindow.QuitApplication(); + await mainWindow.RequestQuitApplicationAsync( + () => CoreTools.ScheduleRelaunchAfterExit(executablePath)); return; } + CoreTools.ScheduleRelaunchAfterExit(executablePath); (global::Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/ApplicationShutdownCoordinator.cs b/src/UniGetUI.Avalonia/Infrastructure/ApplicationShutdownCoordinator.cs new file mode 100644 index 0000000000..8bd4fb3037 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/ApplicationShutdownCoordinator.cs @@ -0,0 +1,47 @@ +namespace UniGetUI.Avalonia.Infrastructure; + +internal sealed class ApplicationShutdownCoordinator +{ + private int _isQuitting; + private int _requestPending; + + public bool IsQuitting => Volatile.Read(ref _isQuitting) != 0; + + public async Task RequestAsync( + Func> authorizeShutdown, + Func shutdown, + Action? onAuthorized = null) + { + ArgumentNullException.ThrowIfNull(authorizeShutdown); + ArgumentNullException.ThrowIfNull(shutdown); + + if (IsQuitting || Interlocked.Exchange(ref _requestPending, 1) != 0) + return false; + + try + { + if (!await authorizeShutdown()) + return false; + + if (Interlocked.Exchange(ref _isQuitting, 1) != 0) + return false; + + try + { + onAuthorized?.Invoke(); + } + catch + { + Interlocked.Exchange(ref _isQuitting, 0); + throw; + } + + await shutdown(); + return true; + } + finally + { + Interlocked.Exchange(ref _requestPending, 0); + } + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 9beb29fed0..d7d9ce4d2f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -115,6 +115,11 @@ private sealed record Entry( new("Ask only once for administrator privileges", ["admin once", "cache admin rights"], typeof(Administrator), "CacheAdminOnceCard"), new("Prohibit any kind of Elevation via UniGetUI Elevator or GSudo", ["prohibit elevation", "no elevation"], typeof(Administrator), "ProhibitElevationCard"), new("Inspect active package broker policy", ["policy", "package broker", "devolutions agent", "rules", "enforcement"], typeof(AgentPolicyInspector), null, WindowsOnly: true), + new("Policy management", ["policy management", "policy state", "active", "missing", "invalid", "configured path"], typeof(AgentPolicyInspector), "PolicyManagementHeading", WindowsOnly: true), + new("Edit the active policy", ["edit policy", "policy editor"], typeof(AgentPolicyInspector), "EditPolicyButton", WindowsOnly: true), + new("Create a new policy", ["create policy", "new policy"], typeof(AgentPolicyInspector), "CreatePolicyButton", WindowsOnly: true), + new("Repair the stored policy", ["repair policy", "invalid policy", "fix policy"], typeof(AgentPolicyInspector), "RepairPolicyButton", WindowsOnly: true), + new("Replace the active policy identity", ["replace identity", "replace policy"], typeof(AgentPolicyInspector), "ReplaceIdentityButton", WindowsOnly: true), new("Allow custom command-line arguments", ["command line arguments", "cli arguments"], typeof(Administrator), "AdminRestrictionsOpsCard"), new("Ignore custom pre-install and post-install commands when importing packages from a bundle", ["pre-install commands", "post-install commands"], typeof(Administrator), "PrePostCommandCard"), new("Allow changing the paths for package manager executables", ["manager paths", "executable path"], typeof(Administrator), "AdminManagerPathsCard"), diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 471725a2f1..86abd152a9 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -157,9 +157,62 @@ /> + + + <_PolicyHelperProject>$(MSBuildThisFileDirectory)..\UniGetUI.AgentPolicy.ElevatedHelper\UniGetUI.AgentPolicy.ElevatedHelper.csproj + <_PolicyHelperPlatform Condition="'$(RuntimeIdentifier)' == 'win-x64'">x64 + <_PolicyHelperPlatform Condition="'$(RuntimeIdentifier)' == 'win-arm64'">arm64 + + <_PolicyHelperIntermediateDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)$(BaseIntermediateOutputPath)policy-elevator\$(Configuration)\$(RuntimeIdentifier)')) + <_PolicyHelperExecutable>$(_PolicyHelperIntermediateDir)\UniGetUI.PolicyElevator.exe + <_PolicyHelperDestination>$(PublishDir)Assets\Utilities\ + + + + + + + + + + + _currentPage; private readonly List NavigationHistory = new(); + private readonly SemaphoreSlim _navigationSemaphore = new(1, 1); [ObservableProperty] private object? _currentPageContent; @@ -632,66 +633,91 @@ public static PageType GetPreviousPage(PageType type) => _ => PageType.Discover, }; - public void NavigateTo(PageType newPage_t, bool toHistory = true) + public void NavigateTo(PageType newPage_t, bool toHistory = true) => + _ = NavigateToAsync(newPage_t, toHistory); + + public async Task NavigateToAsync( + PageType newPage_t, + bool toHistory = true, + CancellationToken cancellationToken = default) { - if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return; } - if (newPage_t is PageType.Quit) { MainWindow.Instance?.QuitApplication(); return; } + if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return true; } + if (newPage_t is PageType.Quit) { MainWindow.Instance?.QuitApplication(); return true; } if (_currentPage == newPage_t) { // Re-focus the primary control even when we're already on the page (CurrentPageContent as AbstractPackagesPage)?.FocusPackageList(); - return; + return true; } - Sidebar.SelectNavButtonForPage(newPage_t); + await _navigationSemaphore.WaitAsync(cancellationToken); + try + { + if (_currentPage == newPage_t) + return true; - var newPage = GetPageForType(newPage_t); - var oldPage = CurrentPageContent as Control; + if (CurrentPageContent is IAsyncLeaveGuard guard + && !await guard.CanLeaveAsync(PageLeaveReason.TopLevelNavigation, cancellationToken)) + { + Sidebar.SelectNavButtonForPage(_currentPage); + return false; + } - if (oldPage is ISearchBoxPage oldSPage) - oldSPage.QueryBackup = GlobalSearchText; - (oldPage as IEnterLeaveListener)?.OnLeave(); + Sidebar.SelectNavButtonForPage(newPage_t); - CurrentPageContent = newPage; - _oldPage = _currentPage; - _currentPage = newPage_t; + var newPage = GetPageForType(newPage_t); + var oldPage = CurrentPageContent as Control; - // #5129: Help/ReleaseNotes each host a WebView2 that the control never releases on - // detach. Drop the page when leaving so its WebView2 process cluster gets freed. - ReleaseWebViewPage(oldPage); + if (oldPage is ISearchBoxPage oldSPage) + oldSPage.QueryBackup = GlobalSearchText; + (oldPage as IEnterLeaveListener)?.OnLeave(); - if (toHistory && _oldPage is not PageType.Null) - { - NavigationHistory.Add(_oldPage); - CanGoBackChanged?.Invoke(this, true); - } + CurrentPageContent = newPage; + _oldPage = _currentPage; + _currentPage = newPage_t; - (newPage as AbstractPackagesPage)?.FilterPackages(); - (newPage as IEnterLeaveListener)?.OnEnter(); + // #5129: Help/ReleaseNotes each host a WebView2 that the control never releases on + // detach. Drop the page when leaving so its WebView2 process cluster gets freed. + ReleaseWebViewPage(oldPage); - CloseSuggestions(); + if (toHistory && _oldPage is not PageType.Null) + { + NavigationHistory.Add(_oldPage); + CanGoBackChanged?.Invoke(this, true); + } - if (newPage is ISearchBoxPage newSPage) - { - SubscribeToPageViewModel(newPage as AbstractPackagesPage); - GlobalSearchText = newSPage.QueryBackup; - GlobalSearchPlaceholder = newSPage.SearchBoxPlaceholder; - GlobalSearchEnabled = true; + (newPage as AbstractPackagesPage)?.FilterPackages(); + (newPage as IEnterLeaveListener)?.OnEnter(); + + CloseSuggestions(); + + if (newPage is ISearchBoxPage newSPage) + { + SubscribeToPageViewModel(newPage as AbstractPackagesPage); + GlobalSearchText = newSPage.QueryBackup; + GlobalSearchPlaceholder = newSPage.SearchBoxPlaceholder; + GlobalSearchEnabled = true; + } + else + { + SubscribeToPageViewModel(null); + GlobalSearchText = ""; + GlobalSearchPlaceholder = ""; + GlobalSearchEnabled = false; + } + + // Focus after search state is restored so MegaQueryVisible is already correct + (newPage as AbstractPackagesPage)?.FocusPackageList(); + + AccessibilityAnnouncementService.Announce(GetPageAnnouncement(newPage_t)); + CurrentPageChanged?.Invoke(this, newPage_t); + return true; } - else + finally { - SubscribeToPageViewModel(null); - GlobalSearchText = ""; - GlobalSearchPlaceholder = ""; - GlobalSearchEnabled = false; + _navigationSemaphore.Release(); } - - // Focus after search state is restored so MegaQueryVisible is already correct - (newPage as AbstractPackagesPage)?.FocusPackageList(); - - AccessibilityAnnouncementService.Announce(GetPageAnnouncement(newPage_t)); - CurrentPageChanged?.Invoke(this, newPage_t); } private static string GetPageAnnouncement(PageType pageType) => pageType switch @@ -710,52 +736,96 @@ public void NavigateTo(PageType newPage_t, bool toHistory = true) _ => CoreTools.Translate("UniGetUI"), }; - public void NavigateBack() + public void NavigateBack() => _ = NavigateBackAsync(); + + public async Task NavigateBackAsync(CancellationToken cancellationToken = default) { if (CurrentPageContent is IInnerNavigationPage navPage && navPage.CanGoBack()) { - navPage.GoBack(); + return await navPage.GoBackAsync(cancellationToken); } else if (NavigationHistory.Count > 0) { - NavigateTo(NavigationHistory.Last(), toHistory: false); + if (!await NavigateToAsync( + NavigationHistory.Last(), + toHistory: false, + cancellationToken)) + return false; + NavigationHistory.RemoveAt(NavigationHistory.Count - 1); CanGoBackChanged?.Invoke(this, NavigationHistory.Count > 0 || ((CurrentPageContent as IInnerNavigationPage)?.CanGoBack() ?? false)); + return true; } + + return false; } public void OpenManagerLogs(IPackageManager? manager = null) + => _ = OpenManagerLogsAsync(manager); + + private async Task OpenManagerLogsAsync(IPackageManager? manager) { - NavigateTo(PageType.ManagerLog); - if (manager is not null) ManagerLogPage?.LoadForManager(manager); + if (!await NavigateToAsync(PageType.ManagerLog)) + return; + if (manager is not null) + ManagerLogPage?.LoadForManager(manager); } public void OpenManagerSettings(IPackageManager? manager = null) + => _ = OpenManagerSettingsAsync(manager); + + private async Task OpenManagerSettingsAsync(IPackageManager? manager) { - NavigateTo(PageType.Managers); - if (manager is not null) ManagersPage?.NavigateTo(manager); + if (!await NavigateToAsync(PageType.Managers)) + return; + if (manager is not null && ManagersPage is not null) + await ManagersPage.NavigateToAsync(manager); } public void OpenSettingsPage(Type page, string? anchor = null) + => _ = OpenSettingsPageAsync(page, anchor); + + private async Task OpenSettingsPageAsync(Type page, string? anchor) { - NavigateTo(PageType.Settings); - SettingsPage?.NavigateTo(page, anchor); + if (!await NavigateToAsync(PageType.Settings)) + return; + if (SettingsPage is not null) + await SettingsPage.NavigateToAsync(page, anchor); } public void ShowHelp(string uriAttachment = "") + => _ = ShowHelpAsync(uriAttachment); + + private async Task ShowHelpAsync(string uriAttachment) { - NavigateTo(PageType.Help); + if (!await NavigateToAsync(PageType.Help)) + return; HelpPage?.NavigateTo(uriAttachment); } public async Task LoadCloudBundleAsync(string content) { - NavigateTo(PageType.Bundles); + if (!await NavigateToAsync(PageType.Bundles)) + return; await BundlesPage.OpenFromString(content, BundleFormatType.UBUNDLE, "GitHub Gist"); } + public async Task CanShutdownAsync(CancellationToken cancellationToken = default) + { + await _navigationSemaphore.WaitAsync(cancellationToken); + try + { + return CurrentPageContent is not IAsyncLeaveGuard guard + || await guard.CanLeaveAsync(PageLeaveReason.Shutdown, cancellationToken); + } + finally + { + _navigationSemaphore.Release(); + } + } + private async Task ShowAboutDialog() { Sidebar.SelectNavButtonForPage(PageType.Null); @@ -766,7 +836,7 @@ private async Task ShowAboutDialog() } // ─── Notification activation ───────────────────────────────────────────── - private void HandleNotificationActivation(string action) + private async void HandleNotificationActivation(string action) { if (action == NotificationArguments.UpdateAllPackages) { @@ -774,8 +844,8 @@ private void HandleNotificationActivation(string action) } else if (action == NotificationArguments.ShowOnUpdatesTab) { - NavigateTo(PageType.Updates); - MainWindow.Instance?.ShowFromTray(); + if (await NavigateToAsync(PageType.Updates)) + MainWindow.Instance?.ShowFromTray(); } else if (action == NotificationArguments.Show) { diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs index a85d49b0ce..0a3bc7b94f 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs @@ -7,8 +7,11 @@ using Devolutions.Now.Policy.Model; using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.AgentBroker; +using UniGetUI.PackageEngine.AgentBroker.PolicyManagement; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; using PolicyArchitecture = Devolutions.Now.Policy.Model.Architecture; using PolicyDecision = Devolutions.Now.Policy.Model.Decision; using PolicyElevation = Devolutions.Now.Policy.Model.Elevation; @@ -23,6 +26,19 @@ public sealed record PolicyDetailRow(string Label, string Value) public string AutomationName => $"{Label}: {Value}"; } +/// +/// Raised by when the user chooses Edit/Create/Repair/Replace +/// identity. Carries everything the (view-owned) dialog launcher needs to construct a +/// PolicyEditorSession without the view model itself depending on any Avalonia window/dialog type. +/// is populated for Create/Repair/ReplaceIdentity (there is no existing valid +/// draft to derive from); Update leaves it null since PolicyEditorSession.StartUpdate derives the +/// draft from itself. +/// +public sealed record PolicyEditorLaunchRequest( + PolicyEditorOperationKind Operation, + PolicyManagementSnapshot Management, + PolicyEditorDraftDocument? SeedDraft = null); + public sealed class PolicyRuleViewModel { public required string AutomationName { get; init; } @@ -39,10 +55,17 @@ public partial class AgentPolicyInspectorViewModel : ViewModelBase, IDisposable { private readonly IBrokerPolicyInspector _inspector; private readonly Action _announce; + private readonly IBrokerPolicyManagementService _managementService; + private readonly IPolicyWriteElevationEligibility _writeElevationEligibility; private readonly CancellationTokenSource _lifetimeCancellation = new(); private CancellationTokenSource? _refreshCancellation; + private CancellationTokenSource? _managementRefreshCancellation; private long _refreshGeneration; + private long _managementRefreshGeneration; + private long _appliedManagementGeneration; private int _isDisposed; + private BrokerPolicyInspectionResult? _inspectionResult; + private PolicyManagementSnapshot? _managementSnapshot; public InfoBarViewModel Status { get; } = new() { @@ -50,19 +73,52 @@ public partial class AgentPolicyInspectorViewModel : ViewModelBase, IDisposable IsOpen = true, }; + /// Status for the independent Phase 2 management-state section (Active/Missing/Invalid). + public InfoBarViewModel ManagementStatus { get; } = new() + { + IsClosable = false, + IsOpen = true, + }; + public ObservableCollection MetadataRows { get; } = []; public ObservableCollection EnforcementRows { get; } = []; public ObservableCollection Rules { get; } = []; + /// Sanitized Invalid-state findings, or empty when the snapshot is not Invalid. + public ObservableCollection ManagementDiagnosticsRows { get; } = []; + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private bool _isPageRefreshActive; + [ObservableProperty] private bool _isActivePolicyInspectionVisible = true; [ObservableProperty] private bool _hasPolicy; [ObservableProperty] private bool _hasNoRules; [ObservableProperty] private string _rawJson = ""; + [ObservableProperty] private bool _isManagementLoading; + [ObservableProperty] private bool _hasManagementSnapshot; + [ObservableProperty] private string _managementStateText = ""; + [ObservableProperty] private string _managementConfiguredPath = ""; + [ObservableProperty] private string _managementSourceText = ""; + [ObservableProperty] private string _agentWriteCapabilityText = ""; + [ObservableProperty] private string _policyChangesFromThisAppText = ""; + [ObservableProperty] private string _policyChangesReasonText = ""; + [ObservableProperty] private bool _managementElevationRequired; + [ObservableProperty] private string _managementElevationRequiredText = ""; + [ObservableProperty] private bool _canEdit; + [ObservableProperty] private bool _canCreate; + [ObservableProperty] private bool _canRepair; + [ObservableProperty] private bool _canReplaceIdentity; + [ObservableProperty] private bool _hasManagementDiagnostics; + public event EventHandler? CopyTextRequested; + public event EventHandler? OpenPolicyEditorRequested; public AgentPolicyInspectorViewModel() - : this(new BrokerPolicyInspector()) + : this( + new BrokerPolicyInspector(), + new BrokerPolicyManagementService(), + new PackagedPolicyWriteElevationEligibility(), + AccessibilityAnnouncementService.Announce) { } @@ -74,17 +130,98 @@ public AgentPolicyInspectorViewModel(IBrokerPolicyInspector inspector) internal AgentPolicyInspectorViewModel( IBrokerPolicyInspector inspector, Action announce) + : this( + inspector, + new BrokerPolicyManagementService(), + new PackagedPolicyWriteElevationEligibility(), + announce) + { + } + + public AgentPolicyInspectorViewModel( + IBrokerPolicyInspector inspector, + IBrokerPolicyManagementService managementService) + : this( + inspector, + managementService, + new PackagedPolicyWriteElevationEligibility(), + AccessibilityAnnouncementService.Announce) + { + } + + internal AgentPolicyInspectorViewModel( + IBrokerPolicyInspector inspector, + IBrokerPolicyManagementService managementService, + Action announce) + : this( + inspector, + managementService, + new PackagedPolicyWriteElevationEligibility(), + announce) + { + } + + internal AgentPolicyInspectorViewModel( + IBrokerPolicyInspector inspector, + IBrokerPolicyManagementService managementService, + IPolicyWriteElevationEligibility writeElevationEligibility, + Action announce) { _inspector = inspector; _announce = announce; + _managementService = managementService; + _writeElevationEligibility = writeElevationEligibility; SetStatus( CoreTools.Translate("Loading active package broker policy"), CoreTools.Translate("Contacting the Devolutions Agent service."), InfoBarSeverity.Informational); + SetManagementStatus( + CoreTools.Translate("Loading policy management state"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); } public Task LoadAsync() => RefreshAsync(); + /// + /// Kept independent from (and its own + /// dependency default) so Phase 1's inspector behavior and tests - which construct this view model with + /// only a stub - are unaffected by the Phase 2 management surface. + /// + public Task LoadManagementAsync() => RefreshManagementAsync(); + + [RelayCommand(CanExecute = nameof(CanRefreshPage))] + private async Task RefreshPageAsync() + { + if (!CanRefreshPage()) return; + + IsPageRefreshActive = true; + RefreshPageCommand.NotifyCanExecuteChanged(); + try + { + Task management = RefreshManagementAsync(); + Task inspection = RefreshAsync(); + await Task.WhenAll(management, inspection); + } + finally + { + IsPageRefreshActive = false; + RefreshPageCommand.NotifyCanExecuteChanged(); + } + } + + private bool CanRefreshPage() => + Volatile.Read(ref _isDisposed) == 0 + && !IsPageRefreshActive + && !IsLoading + && !IsManagementLoading; + + partial void OnIsLoadingChanged(bool value) => + RefreshPageCommand.NotifyCanExecuteChanged(); + + partial void OnIsManagementLoadingChanged(bool value) => + RefreshPageCommand.NotifyCanExecuteChanged(); + [RelayCommand(AllowConcurrentExecutions = true)] private async Task RefreshAsync() { @@ -97,11 +234,8 @@ private async Task RefreshAsync() previous?.Dispose(); IsLoading = true; - HasPolicy = false; - SetStatus( - CoreTools.Translate("Loading active package broker policy"), - CoreTools.Translate("Contacting the Devolutions Agent service."), - InfoBarSeverity.Informational); + _inspectionResult = null; + UpdateInspectionPresentation(); try { @@ -144,6 +278,112 @@ internal void ReportCopyFailure() AnnounceStatus(); } + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task RefreshManagementAsync() + { + if (Volatile.Read(ref _isDisposed) != 0) return; + + long generation = Interlocked.Increment(ref _managementRefreshGeneration); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); + CancellationTokenSource? previous = Interlocked.Exchange(ref _managementRefreshCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + + IsManagementLoading = true; + SetManagementStatus( + CoreTools.Translate("Loading policy management state"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); + UpdateInspectionPresentation(); + + try + { + BrokerPolicyManagementResult result = + await _managementService.GetManagementAsync(cancellation.Token); + if (!CanApplyManagement(generation, cancellation)) return; + + PolicyWriteElevationEligibility writeEligibility = + PolicyWriteElevationEligibility.Eligible; + if (result is + { + Status: BrokerPolicyManagementStatus.Retrieved, + Snapshot.WriteCapability: PolicyWriteCapability.Writable, + }) + { + writeEligibility = await _writeElevationEligibility + .EvaluateAsync(cancellation.Token); + if (!CanApplyManagement(generation, cancellation)) return; + } + + _appliedManagementGeneration = generation; + ApplyManagementResult(result, writeEligibility); + UpdateInspectionPresentation(); + AnnounceManagementStatus(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + if (CanApplyManagement(generation, cancellation)) + { + IsManagementLoading = false; + } + } + } + + [RelayCommand] + private void EditPolicy() + { + if (!CanEdit || _managementSnapshot is not { State: PolicyManagementState.Active } snapshot) return; + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.Update, snapshot)); + } + + [RelayCommand] + private void ReplaceIdentity() + { + if (!CanReplaceIdentity + || _managementSnapshot is not { State: PolicyManagementState.Active, Policy: not null } snapshot) + { + return; + } + + PolicyEditorDraftDocument seed = PolicyEditorTemplates.CreateNew( + PolicyEditorTemplates.CreateReplacementId(snapshot.Policy.Metadata.Id), + snapshot.Policy.Metadata.Publisher); + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.ReplaceIdentity, snapshot, seed)); + } + + [RelayCommand] + private void CreatePolicy() + { + if (!CanCreate || _managementSnapshot is not { State: PolicyManagementState.Missing } snapshot) return; + + PolicyEditorDraftDocument seed = PolicyEditorTemplates.CreateNew( + "new-policy", + CoreTools.Translate("Your organization")); + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.Create, snapshot, seed)); + } + + [RelayCommand] + private void RepairPolicy() + { + if (!CanRepair || _managementSnapshot is not { State: PolicyManagementState.Invalid } snapshot) return; + + PolicyEditorDraftDocument seed = PolicyEditorTemplates.CreateNew( + "repaired-policy", + CoreTools.Translate("Your organization")); + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.Repair, snapshot, seed)); + } + private bool CanApply(long generation, CancellationTokenSource cancellation) { return Volatile.Read(ref _isDisposed) == 0 @@ -151,10 +391,45 @@ private bool CanApply(long generation, CancellationTokenSource cancellation) && generation == Volatile.Read(ref _refreshGeneration); } + private bool CanApplyManagement(long generation, CancellationTokenSource cancellation) + { + return Volatile.Read(ref _isDisposed) == 0 + && !cancellation.IsCancellationRequested + && generation == Volatile.Read(ref _managementRefreshGeneration); + } + private void ApplyResult(BrokerPolicyInspectionResult result) + { + _inspectionResult = result; + UpdateInspectionPresentation(); + } + + private void UpdateInspectionPresentation() { ClearPolicy(); + bool hasCurrentMissingSnapshot = + _appliedManagementGeneration == _managementRefreshGeneration + && _managementSnapshot is { State: PolicyManagementState.Missing }; + IsActivePolicyInspectionVisible = !hasCurrentMissingSnapshot; + if (hasCurrentMissingSnapshot) + { + SetStatus( + CoreTools.Translate("No active package policy"), + CoreTools.Translate("Devolutions Agent reports that no policy file exists at the configured path."), + InfoBarSeverity.Informational); + return; + } + + if (_inspectionResult is not { } result) + { + SetStatus( + CoreTools.Translate("Loading active package broker policy"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); + return; + } + switch (result.Status) { case BrokerPolicyInspectionStatus.Connected when result.Response is not null: @@ -163,7 +438,7 @@ private void ApplyResult(BrokerPolicyInspectionResult result) case BrokerPolicyInspectionStatus.AgentUnavailable: SetStatus( CoreTools.Translate("Devolutions Agent is unavailable"), - CoreTools.Translate("The package broker could not be reached. Verify that Devolutions Agent is installed and running, then refresh."), + CoreTools.Translate("Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh."), InfoBarSeverity.Error); break; case BrokerPolicyInspectionStatus.Unsupported: @@ -348,6 +623,213 @@ private void ClearPolicy() HasNoRules = false; } + private void ApplyManagementResult( + BrokerPolicyManagementResult result, + PolicyWriteElevationEligibility writeEligibility) + { + ClearManagement(); + + switch (result.Status) + { + case BrokerPolicyManagementStatus.Retrieved when result.Snapshot is not null: + ApplyManagementSnapshot(result.Snapshot, result.Diagnostics, writeEligibility); + break; + case BrokerPolicyManagementStatus.AgentUnavailable: + SetManagementStatus( + CoreTools.Translate("Devolutions Agent is unavailable"), + CoreTools.Translate("Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.Unsupported: + SetManagementStatus( + CoreTools.Translate("Policy management is unsupported"), + CoreTools.Translate("The installed Devolutions Agent is reachable but does not support policy management. Update the Agent and try again."), + InfoBarSeverity.Warning); + break; + case BrokerPolicyManagementStatus.AccessDenied: + SetManagementStatus( + CoreTools.Translate("Access to policy management was denied"), + CoreTools.Translate("Devolutions Agent did not authorize UniGetUI to manage the package policy."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.InvalidResponse: + SetManagementStatus( + CoreTools.Translate("The policy management response is invalid"), + CoreTools.Translate("Devolutions Agent returned a malformed or incompatible policy management response."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.UnsupportedPlatform: + SetManagementStatus( + CoreTools.Translate("Policy management is available on Windows only"), + CoreTools.Translate("This page cannot manage the policy file through the Windows Devolutions Agent service on the current platform."), + InfoBarSeverity.Warning); + break; + case BrokerPolicyManagementStatus.UnsafePolicyPath: + SetManagementStatus( + CoreTools.Translate("The configured policy path is unsafe"), + CoreTools.Translate("Devolutions Agent refused to manage the configured policy path because it is considered unsafe (for example, a path traversal or reparse point)."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.UnsupportedPolicyFormat: + SetManagementStatus( + CoreTools.Translate("The policy file format is unsupported"), + CoreTools.Translate("Devolutions Agent reported that the configured policy file format is not supported for management."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.UnsupportedPolicyFilesystem: + SetManagementStatus( + CoreTools.Translate("The policy file system is unsupported"), + CoreTools.Translate("Devolutions Agent reported that the file system hosting the configured policy path is not supported for management."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.PolicyUnavailable: + SetManagementStatus( + CoreTools.Translate("The policy management state is unavailable"), + CoreTools.Translate("Devolutions Agent supports policy management but could not provide the current state. Review the Agent configuration and try again."), + InfoBarSeverity.Error); + break; + default: + SetManagementStatus( + CoreTools.Translate("The policy management response is invalid"), + CoreTools.Translate("Devolutions Agent returned a malformed or incompatible policy management response."), + InfoBarSeverity.Error); + break; + } + } + + private void ApplyManagementSnapshot( + PolicyManagementSnapshot snapshot, + BrokerPolicyDiagnosticsView? diagnostics, + PolicyWriteElevationEligibility writeEligibility) + { + _managementSnapshot = snapshot; + HasManagementSnapshot = true; + + ManagementStateText = TranslateEnum(snapshot.State); + ManagementConfiguredPath = Value(PolicyFindingPresentation.SanitizeAgentText( + snapshot.ConfiguredPath, + BrokerPolicyManagementLimits.MaxSanitizedPathLength)); + ManagementSourceText = TranslateEnum(snapshot.Source); + AgentWriteCapabilityText = snapshot.WriteCapability switch + { + PolicyWriteCapability.Writable => CoreTools.Translate("Writable"), + PolicyWriteCapability.ReadOnly => CoreTools.Translate("Read-only"), + PolicyWriteCapability.Unsupported => CoreTools.Translate("Unsupported"), + _ => CoreTools.Translate("Unknown"), + }; + ManagementElevationRequired = snapshot.ElevationRequired; + ManagementElevationRequiredText = FormatBoolean(snapshot.ElevationRequired); + + bool agentWritable = snapshot.WriteCapability == PolicyWriteCapability.Writable; + bool writable = agentWritable && writeEligibility.IsEligible; + PolicyChangesFromThisAppText = writable + ? CoreTools.Translate("Available") + : CoreTools.Translate("Unavailable"); + PolicyChangesReasonText = writable + ? CoreTools.Translate("Not applicable") + : agentWritable + ? GetElevationEligibilityReason(writeEligibility.Status) + : snapshot.ReadOnlyReason.HasValue + ? GetAgentReadOnlyReason(snapshot.ReadOnlyReason.Value) + : CoreTools.Translate("Devolutions Agent does not allow policy changes."); + + CanEdit = writable && snapshot.State == PolicyManagementState.Active; + CanCreate = writable && snapshot.State == PolicyManagementState.Missing; + CanRepair = writable && snapshot.State == PolicyManagementState.Invalid; + CanReplaceIdentity = writable + && snapshot.State == PolicyManagementState.Active + && PolicyEditorTemplates.IsValidResourceId(snapshot.Policy?.Metadata.Id); + + if (diagnostics is not null) + { + foreach (BrokerPolicySanitizedFinding finding in diagnostics.Findings) + { + ManagementDiagnosticsRows.Add(BuildDiagnosticRow(finding)); + } + + if (diagnostics.FindingsTruncated) + { + ManagementDiagnosticsRows.Add(new PolicyDetailRow( + CoreTools.Translate("Note"), + CoreTools.Translate("Additional findings were omitted."))); + } + } + + HasManagementDiagnostics = ManagementDiagnosticsRows.Count > 0; + + switch (snapshot.State) + { + case PolicyManagementState.Active: + SetManagementStatus( + CoreTools.Translate("Policy management is active"), + CoreTools.Translate("A valid policy file is configured and in effect."), + InfoBarSeverity.Success); + break; + case PolicyManagementState.Missing: + SetManagementStatus( + CoreTools.Translate("No policy file exists"), + CoreTools.Translate("Create a new policy file to start enforcing package broker rules."), + InfoBarSeverity.Informational); + break; + case PolicyManagementState.Invalid: + SetManagementStatus( + CoreTools.Translate("The configured policy file is invalid"), + CoreTools.Translate("Review the diagnostics below and repair the policy file."), + InfoBarSeverity.Warning); + break; + default: + SetManagementStatus( + CoreTools.Translate("The policy management state is invalid"), + CoreTools.Translate("Devolutions Agent returned an unrecognized policy management state."), + InfoBarSeverity.Error); + break; + } + } + + private static string GetElevationEligibilityReason( + PolicyWriteElevationEligibilityStatus status) => + status switch + { + PolicyWriteElevationEligibilityStatus.HelperMissing => CoreTools.Translate( + "The signed policy write helper is missing. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes."), + PolicyWriteElevationEligibilityStatus.ProtectedInstallRequired => CoreTools.Translate( + "Policy changes are disabled because this UniGetUI installation is not administrator-protected. Reinstall UniGetUI for all users in an administrator-protected location to enable them."), + _ => CoreTools.Translate( + "This UniGetUI installation cannot securely launch the policy write helper. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes."), + }; + + private static string GetAgentReadOnlyReason(PolicyReadOnlyReason reason) => + reason switch + { + PolicyReadOnlyReason.ManagementDisabled => + CoreTools.Translate("Policy management is disabled in Devolutions Agent."), + PolicyReadOnlyReason.PathNotConfigured => + CoreTools.Translate("No policy path is configured in Devolutions Agent."), + PolicyReadOnlyReason.UnsupportedFormat => + CoreTools.Translate("Devolutions Agent does not support the configured policy format."), + PolicyReadOnlyReason.UnsafePath => + CoreTools.Translate("Devolutions Agent considers the configured policy path unsafe."), + PolicyReadOnlyReason.InsufficientPermissions => + CoreTools.Translate("Devolutions Agent does not have permission to change the policy file."), + PolicyReadOnlyReason.UnsupportedFileSystem => + CoreTools.Translate("Devolutions Agent does not support the policy file system."), + _ => CoreTools.Translate("Devolutions Agent does not allow policy changes."), + }; + + private static PolicyDetailRow BuildDiagnosticRow(BrokerPolicySanitizedFinding finding) + { + string label = CoreTools.Translate("{0} ({1})", TranslateEnum(finding.Severity), TranslateEnum(finding.Code)); + string location = finding.Path is { Length: > 0 } path + ? (finding.RuleId is { Length: > 0 } ruleId ? $"{path} \u00b7 {ruleId}" : path) + : finding.RuleId is { Length: > 0 } ruleIdOnly ? ruleIdOnly : ""; + string message = PolicyFindingPresentation.Describe( + finding.Code, + finding.Arguments, + finding.Message); + string value = string.IsNullOrEmpty(location) ? message : $"{location}: {message}"; + return new PolicyDetailRow(label, value); + } + private void SetStatus(string title, string message, InfoBarSeverity severity) { Status.Title = title; @@ -368,11 +850,52 @@ private void AnnounceStatus() : AutomationLiveSetting.Polite); } + private void AnnounceManagementStatus() + { + string message = string.IsNullOrEmpty(ManagementStatus.Message) + ? ManagementStatus.Title + : $"{ManagementStatus.Title}. {ManagementStatus.Message}"; + _announce( + message, + ManagementStatus.Severity == InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + private void SetManagementStatus(string title, string message, InfoBarSeverity severity) + { + ManagementStatus.Title = title; + ManagementStatus.Message = message; + ManagementStatus.Severity = severity; + ManagementStatus.IsOpen = true; + } + + private void ClearManagement() + { + ManagementDiagnosticsRows.Clear(); + _managementSnapshot = null; + HasManagementSnapshot = false; + ManagementStateText = ""; + ManagementConfiguredPath = ""; + ManagementSourceText = ""; + AgentWriteCapabilityText = ""; + PolicyChangesFromThisAppText = ""; + PolicyChangesReasonText = ""; + ManagementElevationRequired = false; + ManagementElevationRequiredText = ""; + HasManagementDiagnostics = false; + CanEdit = false; + CanCreate = false; + CanRepair = false; + CanReplaceIdentity = false; + } + public void Dispose() { if (Interlocked.Exchange(ref _isDisposed, 1) != 0) return; _lifetimeCancellation.Cancel(); Interlocked.Exchange(ref _refreshCancellation, null)?.Cancel(); + Interlocked.Exchange(ref _managementRefreshCancellation, null)?.Cancel(); } } diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs new file mode 100644 index 0000000000..1bf3906f06 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs @@ -0,0 +1,35 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A content-based fingerprint of a , used to detect whether the +/// draft has actually changed (dirty tracking) and to tie a warning acknowledgement to the exact +/// draft state it was granted against (see ). +/// Computed from the canonical draft JSON, which omits server-managed metadata. +/// +public readonly struct PolicyEditorDraftFingerprint : IEquatable +{ + private readonly string _canonicalJson; + + private PolicyEditorDraftFingerprint(string canonicalJson) + { + _canonicalJson = canonicalJson; + } + + public static PolicyEditorDraftFingerprint Compute(PolicyEditorDraftDocument draft) + { + ArgumentNullException.ThrowIfNull(draft); + return new PolicyEditorDraftFingerprint(PolicyEditorRawSyntax.ToCanonicalRaw(draft)); + } + + public bool Equals(PolicyEditorDraftFingerprint other) => + string.Equals(_canonicalJson, other._canonicalJson, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is PolicyEditorDraftFingerprint other && Equals(other); + + public override int GetHashCode() => + _canonicalJson is null ? 0 : StringComparer.Ordinal.GetHashCode(_canonicalJson); + + public static bool operator ==(PolicyEditorDraftFingerprint left, PolicyEditorDraftFingerprint right) => left.Equals(right); + + public static bool operator !=(PolicyEditorDraftFingerprint left, PolicyEditorDraftFingerprint right) => !left.Equals(right); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs new file mode 100644 index 0000000000..0e4b764393 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs @@ -0,0 +1,246 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Editable projection of . Deliberately excludes +/// and (server/write-path +/// assigned bookkeeping, never user-edited) and exposes as a fixed, +/// read-only value instead of an editable field: see . +/// Use to convert to/from the wire model, and for a +/// full, independent deep copy (used for snapshots, undo points, and conflict capture). +/// +public sealed class PolicyEditorDraftDocument +{ + public string PolicyType => PolicyEditorPolicyContract.PolicyType; + + public required PolicyFormatVersion PolicyFormatVersion { get; set; } + + public required PolicyEditorDraftMetadata Metadata { get; set; } + + public required PolicyEditorDraftEnforcement Enforcement { get; set; } + + public List Rules { get; set; } = []; + + public PolicyEditorDraftDocument Clone() => new() + { + PolicyFormatVersion = PolicyFormatVersion, + Metadata = Metadata.Clone(), + Enforcement = Enforcement.Clone(), + Rules = Rules.Select(rule => rule.Clone()).ToList(), + }; +} + +/// Editable projection of , minus Revision/PublishedAt. +public sealed class PolicyEditorDraftMetadata +{ + public required string Id { get; set; } + + public required string Publisher { get; set; } + + public DateTimeOffset? ValidFrom { get; set; } + + public DateTimeOffset? ValidUntil { get; set; } + + public string? Description { get; set; } + + public string? SupportUrl { get; set; } + + public PolicyEditorDraftMetadata Clone() => new() + { + Id = Id, + Publisher = Publisher, + ValidFrom = ValidFrom, + ValidUntil = ValidUntil, + Description = Description, + SupportUrl = SupportUrl, + }; +} + +/// +/// Editable projection of . is fixed +/// (see ); only and +/// are user-editable. +/// +public sealed class PolicyEditorDraftEnforcement +{ + public required Decision DefaultDecision { get; set; } + + public RulePrecedence RulePrecedence => PolicyEditorPolicyContract.FixedRulePrecedence; + + public bool? AuditMode { get; set; } + + public PolicyEditorDraftEnforcement Clone() => new() + { + DefaultDecision = DefaultDecision, + AuditMode = AuditMode, + }; +} + +/// Editable projection of a single . +public sealed class PolicyEditorDraftRule +{ + public required string Id { get; set; } + + public bool Enabled { get; set; } = true; + + public uint Priority { get; set; } + + public required Decision Decision { get; set; } + + public string? Reason { get; set; } + + public required PolicyEditorDraftMatch Match { get; set; } + + public PolicyEditorDraftConstraints? Constraints { get; set; } + + /// Deep copy preserving the same rule identity ( included). + public PolicyEditorDraftRule Clone() => new() + { + Id = Id, + Enabled = Enabled, + Priority = Priority, + Decision = Decision, + Reason = Reason, + Match = Match.Clone(), + Constraints = Constraints?.Clone(), + }; + + /// Deep copy under a new rule identity, for use by the "duplicate rule" operation. + public PolicyEditorDraftRule CloneWithNewId(string newId) + { + PolicyEditorDraftRule clone = Clone(); + clone.Id = newId; + return clone; + } +} + +/// +/// Editable projection of . The eight boolean criteria are exposed as +/// instead of List<bool>; see and +/// for the conversion rules. +/// +public sealed class PolicyEditorDraftMatch +{ + public List Operations { get; set; } = []; + + public List Managers { get; set; } = []; + + public List Sources { get; set; } = []; + + public List PackageIdentifiers { get; set; } = []; + + public List PackageNames { get; set; } = []; + + public List Versions { get; set; } = []; + + public PolicyEditorDraftVersionRange? VersionRange { get; set; } + + public List Scopes { get; set; } = []; + + public List Architectures { get; set; } = []; + + public List Elevation { get; set; } = []; + + public TriState Interactive { get; set; } + + public TriState SkipHashCheck { get; set; } + + public TriState PreRelease { get; set; } + + public TriState HasCustomParameters { get; set; } + + public TriState HasCustomInstallLocation { get; set; } + + public TriState HasPrePostCommands { get; set; } + + public TriState HasKillBeforeOperation { get; set; } + + public TriState HasUninstallPrevious { get; set; } + + public PolicyEditorDraftMatch Clone() => new() + { + Operations = [.. Operations], + Managers = [.. Managers], + Sources = [.. Sources], + PackageIdentifiers = [.. PackageIdentifiers], + PackageNames = [.. PackageNames], + Versions = [.. Versions], + VersionRange = VersionRange?.Clone(), + Scopes = [.. Scopes], + Architectures = [.. Architectures], + Elevation = [.. Elevation], + Interactive = Interactive, + SkipHashCheck = SkipHashCheck, + PreRelease = PreRelease, + HasCustomParameters = HasCustomParameters, + HasCustomInstallLocation = HasCustomInstallLocation, + HasPrePostCommands = HasPrePostCommands, + HasKillBeforeOperation = HasKillBeforeOperation, + HasUninstallPrevious = HasUninstallPrevious, + }; +} + +/// Editable projection of . +public sealed class PolicyEditorDraftVersionRange +{ + public string? MinVersion { get; set; } + + public string? MaxVersion { get; set; } + + public bool IncludePrerelease { get; set; } + + public PolicyEditorDraftVersionRange Clone() => new() + { + MinVersion = MinVersion, + MaxVersion = MaxVersion, + IncludePrerelease = IncludePrerelease, + }; +} + +/// Editable projection of (plain booleans, no tri-state). +public sealed class PolicyEditorDraftConstraints +{ + public bool AllowInteractive { get; set; } + + public bool AllowSkipHashCheck { get; set; } + + public bool AllowPreRelease { get; set; } + + public bool AllowCustomInstallLocation { get; set; } + + public List AllowedInstallLocationPatterns { get; set; } = []; + + public bool AllowCustomParameters { get; set; } + + public List AllowedCustomParameters { get; set; } = []; + + public List AllowedCustomParameterPatterns { get; set; } = []; + + public List DeniedCustomParameters { get; set; } = []; + + public bool AllowPrePostCommands { get; set; } + + public bool AllowKillBeforeOperation { get; set; } + + public bool AllowUninstallPrevious { get; set; } + + public bool AllowUpgrade { get; set; } + + public PolicyEditorDraftConstraints Clone() => new() + { + AllowInteractive = AllowInteractive, + AllowSkipHashCheck = AllowSkipHashCheck, + AllowPreRelease = AllowPreRelease, + AllowCustomInstallLocation = AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. AllowedInstallLocationPatterns], + AllowCustomParameters = AllowCustomParameters, + AllowedCustomParameters = [.. AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. DeniedCustomParameters], + AllowPrePostCommands = AllowPrePostCommands, + AllowKillBeforeOperation = AllowKillBeforeOperation, + AllowUninstallPrevious = AllowUninstallPrevious, + AllowUpgrade = AllowUpgrade, + }; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs new file mode 100644 index 0000000000..2a64927b5e --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public sealed record PolicyEditorValidationOutcome( + PolicyValidationResult? Validation, + ErrorCode? ErrorCode = null, + IReadOnlyList? BoundedFindings = null, + int OmittedFindingCount = 0) +{ + public bool Completed => Validation is not null; +} + +public interface IPolicyValidationClient +{ + Task ValidateAsync( + JsonElement draft, + CancellationToken cancellationToken); +} + +public sealed record PolicyEditorWriteRequest( + PolicyReplacementOperation Operation, + PolicyConflictHandling ConflictHandling, + string ExpectedStoreToken, + JsonElement Draft, + string ValidationReceipt, + bool WarningsAcknowledged) +{ + public PolicyReplacementRequest ToSharedRequest() => new() + { + ExpectedStoreToken = ExpectedStoreToken, + Operation = Operation, + ConflictHandling = ConflictHandling, + WarningsAcknowledged = WarningsAcknowledged, + Draft = Draft.Clone(), + ValidationReceipt = ValidationReceipt, + }; +} + +public enum PolicyWriteFailureKind +{ + None, + UacCanceled, + LaunchFailed, + AuthenticationFailed, + ProtocolFailed, + HelperFailed, + BrokerRejected, + WriteResultUnknown, +} + +public sealed record PolicyWriteOutcome( + PolicyReplacementResponse? Response, + ErrorResponse? Error, + PolicyWriteFailureKind FailureKind = PolicyWriteFailureKind.None, + PolicyEditorRetryDecision? ConflictDecision = null, + bool SavedThenSuperseded = false) +{ + public bool Succeeded => Response is not null; + + public static PolicyWriteOutcome Success( + PolicyReplacementResponse response, + bool savedThenSuperseded = false) => + new(response, null, SavedThenSuperseded: savedThenSuperseded); + + public static PolicyWriteOutcome Failure( + PolicyWriteFailureKind kind, + ErrorResponse? error = null, + PolicyEditorRetryDecision? conflictDecision = null) => + new(null, error, kind, conflictDecision); +} + +public interface IPolicyWriteClient +{ + Task WriteAsync( + PolicyEditorWriteRequest request, + CancellationToken cancellationToken); +} + +public sealed record PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind Kind, + PolicyReplacementOperation Operation, + string DraftId, + string ExpectedStoreToken, + PolicyManagementState State, + string? ActivePolicyId, + IReadOnlyList Findings, + int WarningCount = 0); + +public interface IPolicyEditorConfirmationPrompt +{ + Task ConfirmAsync( + PolicyEditorConfirmationRequest request, + CancellationToken cancellationToken); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs new file mode 100644 index 0000000000..f7c786ada4 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs @@ -0,0 +1,369 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia.Automation; +using CommunityToolkit.Mvvm.ComponentModel; +using Devolutions.Now.Policy.Api; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Composite root DataContext for PolicyEditorDialog: bundles the domain +/// together with the UI-only +/// wrapper and a live wrapper collection, so the whole dialog AXAML tree can bind +/// through a single compiled x:DataType instead of juggling several sibling data contexts. +/// The collection is only rebuilt after a structural rule-list operation +/// (add/duplicate/delete/move) or a raw→structured mode switch; ordinary field edits mutate the +/// existing instances in place so bound controls never lose focus. +/// +public sealed class PolicyEditorDialogViewModel : ObservableObject, IDisposable +{ + private readonly Action _announce; + private long _announcedWriteCompletionGeneration; + + public PolicyEditorSessionViewModel Session { get; } + + public PolicyEditorDocumentUi Document { get; } + + public ObservableCollection Rules { get; } = []; + + public InfoBarViewModel Status { get; } = new() { IsClosable = false, IsOpen = false }; + + public PolicyEditorDialogViewModel(PolicyEditorSessionViewModel session) + : this(session, AccessibilityAnnouncementService.Announce) + { + } + + internal PolicyEditorDialogViewModel( + PolicyEditorSessionViewModel session, + Action announce) + { + Session = session; + _announce = announce; + _announcedWriteCompletionGeneration = session.LastWriteCompletion?.Generation ?? 0; + Document = new PolicyEditorDocumentUi(session); + Session.PropertyChanged += OnSessionPropertyChanged; + RebuildRules(); + RefreshStatus(); + } + + public string Title => Session.Session.Operation switch + { + PolicyEditorOperationKind.Update => CoreTools.Translate("Edit policy '{0}'", Session.Draft.Metadata.Id), + PolicyEditorOperationKind.ReplaceIdentity => CoreTools.Translate("Replace active policy identity"), + PolicyEditorOperationKind.Create => CoreTools.Translate("Create a new package broker policy"), + PolicyEditorOperationKind.Repair => CoreTools.Translate("Repair the stored package broker policy"), + _ => CoreTools.Translate("Package broker policy editor"), + }; + + public bool HasWriteFailure => Session.LastWriteFailureKind != PolicyWriteFailureKind.None + || Session.LastErrorCode is not null; + + public string WriteFailureMessage => DescribeWriteFailure(Session.LastWriteFailureKind, Session.LastErrorCode); + + /// + /// Rebuilds every wrapper from the current + /// . Call after any operation that changes the rule + /// list's identity/order (add/duplicate/delete/move, or a raw→structured switch); never on ordinary + /// field edits, which mutate existing wrappers in place instead. + /// + public void RebuildRules() + { + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.Dispose(); + } + Rules.Clear(); + for (int index = 0; index < Session.Rules.Count; index++) + { + Rules.Add(new PolicyEditorRuleUi(Session.Rules[index], index, Session)); + } + } + + public void RefreshStructuredProjection() + { + Document.RefreshFromDraft(); + RebuildRules(); + } + + private void OnSessionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.LastWriteCompletion) + && Session.LastWriteCompletion is { } completion + && completion.Generation > _announcedWriteCompletionGeneration) + { + _announcedWriteCompletionGeneration = completion.Generation; + AnnounceWriteCompletion(completion); + } + + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.Findings)) + { + Document.RefreshFindings(); + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.RefreshFindings(); + } + + PolicyValidationFinding? firstError = Session.Findings.FirstOrDefault( + finding => finding.Severity == PolicyValidationSeverity.Error); + if (firstError is not null) + { + _announce( + firstError.AutomationName, + AutomationLiveSetting.Assertive); + } + else if (Session.Findings.FirstOrDefault() is { } firstWarning) + { + _announce( + firstWarning.AutomationName, + AutomationLiveSetting.Polite); + } + } + + if (e.PropertyName is nameof(PolicyEditorSessionViewModel.LastWriteFailureKind) + or nameof(PolicyEditorSessionViewModel.LastErrorCode)) + { + OnPropertyChanged(nameof(HasWriteFailure)); + OnPropertyChanged(nameof(WriteFailureMessage)); + } + + if (e.PropertyName is nameof(PolicyEditorSessionViewModel.Draft) + or nameof(PolicyEditorSessionViewModel.Operation)) + { + OnPropertyChanged(nameof(Title)); + } + else if (e.PropertyName == nameof(PolicyEditorSessionViewModel.LastSaveSucceeded) + && Session.LastSaveSucceeded + && !Session.SavedWithNewerChanges) + { + RefreshStructuredProjection(); + } + + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.IsIdentityLocked)) + { + Document.NotifyIdentityLockChanged(); + } + + RefreshStatus(); + } + + private void RefreshStatus() + { + if (Session.IsBusy) + { + SetStatus( + CoreTools.Translate("Working…"), + CoreTools.Translate("Contacting Devolutions Agent."), + InfoBarSeverity.Informational); + return; + } + + if (!string.IsNullOrWhiteSpace(Session.StatusMessage)) + { + SetStatus( + CoreTools.Translate("Policy operation in progress"), + Session.StatusMessage, + InfoBarSeverity.Informational); + return; + } + + if (Session.HasLocalInputErrors) + { + SetStatus( + CoreTools.Translate("Correct the highlighted fields"), + Session.LocalInputErrorSummary, + InfoBarSeverity.Error, + announce: false); + return; + } + + if (Session.SyntaxError is { } syntaxError) + { + SetStatus( + Session.SyntaxErrorTitle, + Session.SyntaxErrorMessage, + InfoBarSeverity.Error, + announce: false); + return; + } + + if (Session.SavedWithNewerChanges) + { + SetStatus( + CoreTools.Translate("Policy saved; newer changes remain"), + CoreTools.Translate("The policy was saved, but newer draft changes remain unsaved."), + InfoBarSeverity.Warning, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (Session.SavedThenSuperseded) + { + SetStatus( + CoreTools.Translate("Policy saved, then replaced again"), + CoreTools.Translate("The policy was saved, but another writer replaced it before management state was refreshed."), + InfoBarSeverity.Warning, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (Session.LastSaveSucceeded) + { + SetStatus( + CoreTools.Translate("Policy saved"), + CoreTools.Translate("The package broker policy was saved successfully."), + InfoBarSeverity.Success, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (Session.HasConflict) + { + SetStatus( + CoreTools.Translate("The policy changed since you started editing"), + CoreTools.Translate("Review your changes, then choose Overwrite to save anyway."), + InfoBarSeverity.Warning, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (HasWriteFailure) + { + SetStatus( + CoreTools.Translate("The policy could not be saved"), + WriteFailureMessage, + InfoBarSeverity.Error, + announce: Session.LastWriteFailureKind == PolicyWriteFailureKind.None + || !HasAnnouncedWriteCompletion); + return; + } + + if (Session.HasFindings) + { + int errorCount = Session.Findings.Count(finding => finding.Severity == PolicyValidationSeverity.Error); + SetStatus( + errorCount > 0 + ? CoreTools.Translate("Validation found errors") + : CoreTools.Translate("Validation found warnings"), + CoreTools.Translate("Review the findings below before saving."), + errorCount > 0 ? InfoBarSeverity.Error : InfoBarSeverity.Warning, + announce: false); + return; + } + + Status.IsOpen = false; + } + + private void SetStatus( + string title, + string message, + InfoBarSeverity severity, + bool announce = true) + { + bool changed = !Status.IsOpen + || Status.Title != title + || Status.Message != message + || Status.Severity != severity; + Status.Title = title; + Status.Message = message; + Status.Severity = severity; + Status.IsOpen = true; + if (changed && announce) + { + AnnounceStatus(); + } + } + + private void AnnounceStatus() + { + string message = string.IsNullOrEmpty(Status.Message) + ? Status.Title + : $"{Status.Title}. {Status.Message}"; + _announce( + message, + Status.Severity == InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + private bool HasAnnouncedWriteCompletion => + Session.LastWriteCompletion is { } completion + && completion.Generation <= _announcedWriteCompletionGeneration; + + private void AnnounceWriteCompletion(PolicyEditorWriteCompletion completion) + { + (string title, string message, InfoBarSeverity severity) = completion.Kind switch + { + PolicyEditorWriteCompletionKind.SavedWithNewerChanges => ( + CoreTools.Translate("Policy saved; newer changes remain"), + CoreTools.Translate("The policy was saved, but newer draft changes remain unsaved."), + InfoBarSeverity.Warning), + PolicyEditorWriteCompletionKind.SavedThenSuperseded => ( + CoreTools.Translate("Policy saved, then replaced again"), + CoreTools.Translate("The policy was saved, but another writer replaced it before management state was refreshed."), + InfoBarSeverity.Warning), + PolicyEditorWriteCompletionKind.Saved => ( + CoreTools.Translate("Policy saved"), + CoreTools.Translate("The package broker policy was saved successfully."), + InfoBarSeverity.Success), + PolicyEditorWriteCompletionKind.Conflict => ( + CoreTools.Translate("The policy changed since you started editing"), + CoreTools.Translate("Review your changes, then choose Overwrite to save anyway."), + InfoBarSeverity.Warning), + _ => ( + CoreTools.Translate("The policy could not be saved"), + DescribeWriteFailure(completion.FailureKind, completion.ErrorCode), + InfoBarSeverity.Error), + }; + + _announce( + $"{title}. {message}", + severity == InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + private static string DescribeWriteFailure(PolicyWriteFailureKind kind, ErrorCode? errorCode) + { + string? reason = kind switch + { + PolicyWriteFailureKind.UacCanceled => + CoreTools.Translate("The elevation prompt was dismissed. No changes were saved."), + PolicyWriteFailureKind.LaunchFailed => + CoreTools.Translate("The elevated helper could not be started."), + PolicyWriteFailureKind.AuthenticationFailed => + CoreTools.Translate("The elevated helper could not be authenticated."), + PolicyWriteFailureKind.ProtocolFailed => + CoreTools.Translate("Communication with the elevated helper failed."), + PolicyWriteFailureKind.HelperFailed => + CoreTools.Translate("The elevated helper stopped unexpectedly."), + PolicyWriteFailureKind.BrokerRejected => + CoreTools.Translate("Devolutions Agent rejected the policy replacement."), + PolicyWriteFailureKind.WriteResultUnknown => + CoreTools.Translate("The policy write result is unknown. Refresh policy management state before retrying."), + _ => null, + }; + + if (errorCode is { } code) + { + string codeText = CoreTools.Translate(code.ToString()); + return reason is null + ? CoreTools.Translate("The save failed ({0}).", codeText) + : CoreTools.Translate("{0} ({1})", reason, codeText); + } + + return reason ?? CoreTools.Translate("The save failed."); + } + + public void Dispose() + { + Session.PropertyChanged -= OnSessionPropertyChanged; + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.Dispose(); + } + Session.Dispose(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs new file mode 100644 index 0000000000..92be16624d --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs @@ -0,0 +1,60 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Tri-state representation of a boolean policy match criterion. The wire format represents these +/// as a List<bool> (see PolicyMatch.Interactive and its siblings), where an empty +/// list means "don't care" and a single-element list pins the criterion to that value. The editor +/// only ever produces these three states. The shared contract rejects lists with more than one item. +/// +public enum TriState +{ + Omitted, + False, + True, +} + +/// +/// Which editing surface currently owns the source of truth for a . +/// +public enum PolicyEditorMode +{ + /// The structured is authoritative. + Structured, + + /// The free-form text is authoritative. + Raw, +} + +/// +/// The operation a was opened to perform. This reflects user intent +/// at session-open time; it is distinct from the state-derived retry operation computed by +/// when a save is attempted against a possibly-stale origin. +/// +public enum PolicyEditorOperationKind +{ + Update, + ReplaceIdentity, + Create, + Repair, +} + +/// +/// Severity of a , as reported by the external (Agent-side) +/// semantic validator. +/// +public enum PolicyValidationSeverity +{ + Info, + Warning, + Error, +} + +public enum PolicyEditorConfirmationKind +{ + Warnings, + ReplaceIdentity, + Create, + Repair, + ConfirmOverwrite, + DiscardChanges, +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorHelp.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorHelp.cs new file mode 100644 index 0000000000..b4f98e7329 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorHelp.cs @@ -0,0 +1,88 @@ +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Shared localized help used by tooltips and accessibility descriptions. +public static class PolicyEditorHelp +{ + public static string StructuredMode => T("Edit supported policy fields with guided controls. Fixed and Agent-managed values cannot be changed here."); + public static string RawMode => T("Edit the complete draft JSON. Returning to structured mode requires strict parsing and successful Agent validation."); + public static string PolicyId => T("Stable authored identity for this policy. Replacing an active identity is a separate operation because it changes which policy is recognized."); + public static string Publisher => T("Authored organization or person responsible for the policy."); + public static string PolicyFormatVersion => T("Software-managed policy document format. It is read-only here and is not the Agent-managed policy revision."); + public static string Description => T("Optional authored description. Clear the checkbox to omit this field from the policy."); + public static string SupportUrl => T("Optional authored HTTP or HTTPS page where users can learn about this policy."); + public static string ValidFrom => T("Optional ISO 8601 date and time with an explicit offset. Before this instant, the policy is not yet valid."); + public static string ValidUntil => T("Optional ISO 8601 date and time with an explicit offset. It must be later than Valid from; after it, the policy is expired."); + public static string DefaultDecision => T("Decision used when no enabled rule matches. Deny is the safer fail-closed choice; Allow permits unmatched requests."); + public static string RulePrecedence => T("Fixed evaluation order: lower priority values run first, and Deny wins ties. This software-managed value cannot be changed."); + public static string AuditMode => T("When enabled, decisions are logged but not enforced. Use only for evaluation because denied operations may still proceed."); + public static string AddRule => T("Add a new authored rule after the existing rules."); + public static string RuleEnabled => T("Disabled rules remain in the policy but do not participate in decisions."); + public static string DuplicateRule => T("Copy this rule to create a similar rule. Give the copy a unique rule ID before validation."); + public static string MoveRule => T("Change this rule's document order. Priority determines evaluation; order mainly affects readability."); + public static string DeleteRule => T("Remove this rule from the draft. This cannot be undone after the policy is saved."); + public static string RuleId => T("Unique authored identifier for this rule. Use a stable name so findings and audits can identify it."); + public static string Priority => T("Whole number from 0 through 2147483647. Lower values are evaluated first; Deny wins when priorities tie."); + public static string Decision => T("Effect applied when this enabled rule matches: Allow permits the request and Deny blocks it."); + public static string RuleReason => T("Optional authored explanation for the rule's decision."); + public static string Operations => T("Optional package operations matched by this rule. Select none to match every supported operation."); + public static string Managers => T("Optional package managers matched by this rule. Select none to match every supported manager."); + public static string Sources => T("Optional source identifiers, one per line. Leave empty to match packages from any source."); + public static string PackageIdentifiers => T("Optional exact package identifiers, one per line. Leave empty to match any identifier."); + public static string PackageNames => T("Optional package display names, one per line. Agent support may be limited; validate before saving."); + public static string Versions => T("Optional exact package versions, one per line. Each value must use the version form accepted by the target package manager."); + public static string VersionRange => T("Optionally restrict matching to minimum and maximum versions. Empty bounds are open-ended."); + public static string MinimumVersion => T("Optional inclusive lower version bound. It must use a valid semantic version accepted by the Agent."); + public static string MaximumVersion => T("Optional inclusive upper version bound. It must not be lower than the minimum version."); + public static string IncludePrerelease => T("Include prerelease versions when evaluating this version range."); + public static string Scopes => T("Optional install scopes matched by this rule. Select none to match every scope."); + public static string Architectures => T("Optional package architectures matched by this rule. Select none to match every architecture."); + public static string Elevation => T("Optional requested elevation states matched by this rule. Select none to match elevated and non-elevated requests."); + public static string MatchOption => T("Select this value to include it in the rule's match criteria. No selected values means any value."); + public static string BooleanSelector => T("Choose Any to omit this match, or Yes/No to require that exact request property."); + public static string InteractiveMatch => T("Match whether the operation is interactive. Choose Any to omit this criterion."); + public static string SkipHashMatch => T("Match whether the request skips hash verification. This is security-sensitive; choose Any to omit this criterion."); + public static string PrereleaseMatch => T("Match whether prerelease packages are requested. Choose Any to omit this criterion."); + public static string CustomParametersMatch => T("Match whether custom command-line parameters are present. Choose Any to omit this criterion."); + public static string CustomLocationMatch => T("Match whether a custom install location is requested. Choose Any to omit this criterion."); + public static string PrePostCommandsMatch => T("Match whether pre-operation or post-operation commands are present. Choose Any to omit this criterion."); + public static string KillBeforeMatch => T("Match whether processes may be killed before the operation. Choose Any to omit this criterion."); + public static string UninstallPreviousMatch => T("Match whether uninstalling a previous version is requested. Choose Any to omit this criterion."); + public static string Constraints => T("Optional limits applied after a rule matches. Package dependencies, agreements, reboot behavior, and ordinary hash verification remain controlled by the package manager unless represented below."); + public static string AllowInteractive => T("Allow an interactive package-manager operation after this rule matches."); + public static string AllowSkipHashCheck => T("Allow bypassing package hash verification. This weakens integrity protection and should be narrowly scoped."); + public static string AllowPrerelease => T("Allow installing prerelease package versions after this rule matches."); + public static string AllowCustomLocation => T("Allow a caller-provided install location. Use allowed patterns to restrict where packages may be written."); + public static string LocationPatterns => T("Allowed custom install-location wildcard patterns, one per line. Empty means no pattern restriction when custom locations are allowed."); + public static string AllowCustomParameters => T("Allow caller-provided package-manager arguments. Restrict them with exact values, allowed patterns, or denied values."); + public static string AllowedParameters => T("Exact custom parameters allowed by this rule, one per line."); + public static string AllowedParameterPatterns => T("Wildcard patterns for custom parameters allowed by this rule, one per line."); + public static string DeniedParameters => T("Custom parameters explicitly denied by this rule, one per line. Denials take precedence over allowances."); + public static string AllowPrePostCommands => T("Allow caller-provided commands before or after package operations. This is dangerous and should be narrowly scoped."); + public static string AllowKillBefore => T("Allow terminating processes before the package operation."); + public static string AllowUninstallPrevious => T("Allow uninstalling an existing package version before installing another version."); + public static string AllowUpgrade => T("Allow the operation to upgrade an already installed package."); + public static string Validate => T("Send the current draft to Devolutions Agent for authoritative validation without saving it."); + public static string Save => T("Validate and save the draft. Warnings require acknowledgement, and policy changes may require elevation."); + public static string Overwrite => T("Replace a policy that changed after editing began. Review the conflict carefully because this discards the newer external version."); + public static string Findings => T("Agent validation findings are sanitized and bounded. Use Go to field to navigate to the affected structured control when available."); + public static string GoToFinding => T("Navigate to and focus the structured field associated with this validation finding."); + public static string GoToRawError => T("Focus the raw JSON editor at the document that could not be parsed or represented."); + public static string CanonicalJson => T("Read-only canonical JSON returned for the active policy. Copy it for diagnostics or review."); + public static string CopyCanonicalJson => T("Copy the complete canonical active-policy JSON to the clipboard."); + public static string RefreshPolicy => T("Refresh management state and active-policy inspection together."); + public static string AgentWriteCapability => T("Authoritative write capability reported by Devolutions Agent."); + public static string AppWriteAvailability => T("Whether this UniGetUI installation can perform policy changes, considering both Agent capability and trusted-helper availability."); + public static string AppWriteReason => T("Why policy changes from this app are unavailable. Agent restrictions take precedence over local helper status."); + public static string ElevationRequired => T("Whether Devolutions Agent reports that policy changes require administrator elevation."); + public static string EditPolicy => T("Open the active policy as a draft while preserving its identity and compatible policy format version."); + public static string CreatePolicy => T("Create a new policy draft using the current software-managed policy format version."); + public static string RepairPolicy => T("Replace an invalid stored policy with a new valid draft after review and validation."); + public static string ReplaceIdentity => T("Create a replacement policy with a new policy ID. This intentionally changes the active policy identity and requires confirmation."); + public static string ManagementState => T("Authoritative policy-file state reported by Devolutions Agent."); + public static string ConfiguredPath => T("Agent-managed policy file path. UniGetUI displays this path but does not author it here."); + public static string PathSource => T("How Devolutions Agent selected the policy path, such as its default or configured path."); + + private static string T(string value) => CoreTools.Translate(value); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs new file mode 100644 index 0000000000..30302dd559 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs @@ -0,0 +1,387 @@ +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Explicit, reflection-free, NativeAOT-safe field-by-field mapping between the wire model +/// ( and friends, from Devolutions.Now.Policy.Model) and the editor's +/// draft model ( and friends). Every mapping here also produces a +/// deep copy: no list or nested object is shared between the source and the result, so mutating one +/// side after mapping never affects the other. +/// +public static class PolicyEditorMapper +{ + // ---- PolicyDocument <-> PolicyEditorDraftDocument ------------------------------------------------- + + public static PolicyEditorDraftDocument ToDraft(PolicyDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyEditorDraftDocument + { + PolicyFormatVersion = document.PolicyFormatVersion, + Metadata = ToDraft(document.Metadata), + Enforcement = ToDraft(document.Enforcement), + Rules = document.Rules.Select(ToDraft).ToList(), + }; + } + + public static PolicyEditorDraftDocument ToDraft(PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyEditorDraftDocument + { + PolicyFormatVersion = document.PolicyFormatVersion, + Metadata = new PolicyEditorDraftMetadata + { + Id = document.Metadata.Id, + Publisher = document.Metadata.Publisher, + ValidFrom = document.Metadata.ValidFrom, + ValidUntil = document.Metadata.ValidUntil, + Description = document.Metadata.Description, + SupportUrl = document.Metadata.SupportUrl, + }, + Enforcement = ToDraft(document.Enforcement), + Rules = document.Rules.Select(ToDraft).ToList(), + }; + } + + public static PolicyDraftDocument ToSharedDraft(PolicyEditorDraftDocument draft) + { + ArgumentNullException.ThrowIfNull(draft); + + return new PolicyDraftDocument + { + PolicyFormatVersion = draft.PolicyFormatVersion, + PolicyType = PolicyEditorPolicyContract.PolicyType, + Metadata = new PolicyDraftMetadata + { + Id = draft.Metadata.Id, + Publisher = draft.Metadata.Publisher, + ValidFrom = draft.Metadata.ValidFrom, + ValidUntil = draft.Metadata.ValidUntil, + Description = draft.Metadata.Description, + SupportUrl = draft.Metadata.SupportUrl, + }, + Enforcement = ToDocument(draft.Enforcement), + Rules = draft.Rules.Select(ToDocument).ToList(), + }; + } + + /// Builds a committed document only from authoritative server metadata. + public static PolicyDocument ToDocument( + PolicyEditorDraftDocument draft, + uint revision, + DateTimeOffset publishedAt) + { + ArgumentNullException.ThrowIfNull(draft); + + return new PolicyDocument + { + PolicyType = PolicyEditorPolicyContract.PolicyType, + PolicyFormatVersion = draft.PolicyFormatVersion, + Metadata = ToDocument(draft.Metadata, revision, publishedAt), + Enforcement = ToDocument(draft.Enforcement), + Rules = draft.Rules.Select(ToDocument).ToList(), + }; + } + + /// Deep-clones a wire-model without going through the draft + /// (so / survive intact). + /// Used for origin snapshots and conflict captures. + public static PolicyDocument CloneDocument(PolicyDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyDocument + { + PolicyType = document.PolicyType, + PolicyFormatVersion = document.PolicyFormatVersion, + Metadata = CloneMetadata(document.Metadata), + Enforcement = CloneEnforcement(document.Enforcement), + Rules = document.Rules.Select(CloneRule).ToList(), + }; + } + + public static PolicyDraftDocument CloneDraftDocument(PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return PolicySerializer.DeserializePolicyDraftDocumentStrict(PolicySerializer.Serialize(document)) + ?? throw new InvalidOperationException("Serialized policy draft unexpectedly deserialized as null."); + } + + public static PolicyManagementSnapshot CloneManagementSnapshot( + PolicyManagementSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return new PolicyManagementSnapshot + { + State = snapshot.State, + ConfiguredPath = snapshot.ConfiguredPath, + StoreToken = snapshot.StoreToken, + Source = snapshot.Source, + WriteCapability = snapshot.WriteCapability, + ReadOnlyReason = snapshot.ReadOnlyReason, + ElevationRequired = snapshot.ElevationRequired, + Policy = snapshot.Policy is null ? null : CloneDocument(snapshot.Policy), + // Editor concurrency only needs state, token, capability, and active identity. + // Invalid diagnostics are presented through the separately bounded management view. + InvalidDiagnostics = null, + }; + } + + // ---- Metadata ---------------------------------------------------------------------------- + + private static PolicyEditorDraftMetadata ToDraft(PolicyMetadata metadata) => new() + { + Id = metadata.Id, + Publisher = metadata.Publisher, + ValidFrom = metadata.ValidFrom, + ValidUntil = metadata.ValidUntil, + Description = metadata.Description, + SupportUrl = metadata.SupportUrl, + }; + + private static PolicyMetadata ToDocument(PolicyEditorDraftMetadata draft, uint revision, DateTimeOffset publishedAt) => new() + { + Id = draft.Id, + Publisher = draft.Publisher, + Revision = revision, + PublishedAt = publishedAt, + ValidFrom = draft.ValidFrom, + ValidUntil = draft.ValidUntil, + Description = draft.Description, + SupportUrl = draft.SupportUrl, + }; + + private static PolicyMetadata CloneMetadata(PolicyMetadata metadata) => new() + { + Id = metadata.Id, + Publisher = metadata.Publisher, + Revision = metadata.Revision, + PublishedAt = metadata.PublishedAt, + ValidFrom = metadata.ValidFrom, + ValidUntil = metadata.ValidUntil, + Description = metadata.Description, + SupportUrl = metadata.SupportUrl, + }; + + // ---- Enforcement ------------------------------------------------------------------------- + + private static PolicyEditorDraftEnforcement ToDraft(PolicyEnforcement enforcement) => new() + { + DefaultDecision = enforcement.DefaultDecision, + AuditMode = enforcement.AuditMode, + }; + + private static PolicyEnforcement ToDocument(PolicyEditorDraftEnforcement draft) => new() + { + DefaultDecision = draft.DefaultDecision, + RulePrecedence = PolicyEditorPolicyContract.FixedRulePrecedence, + AuditMode = draft.AuditMode, + }; + + private static PolicyEnforcement CloneEnforcement(PolicyEnforcement enforcement) => new() + { + DefaultDecision = enforcement.DefaultDecision, + RulePrecedence = enforcement.RulePrecedence, + AuditMode = enforcement.AuditMode, + }; + + // ---- Rule / Match / Constraints ---------------------------------------------------------- + + private static PolicyEditorDraftRule ToDraft(PolicyRule rule) => new() + { + Id = rule.Id, + Enabled = rule.Enabled, + Priority = rule.Priority, + Decision = rule.Decision, + Reason = rule.Reason, + Match = ToDraft(rule.Match), + Constraints = rule.Constraints is null ? null : ToDraft(rule.Constraints), + }; + + private static PolicyRule ToDocument(PolicyEditorDraftRule draft) => new() + { + Id = draft.Id, + Enabled = draft.Enabled, + Priority = draft.Priority, + Decision = draft.Decision, + Reason = draft.Reason, + Match = ToDocument(draft.Match), + Constraints = draft.Constraints is null ? null : ToDocument(draft.Constraints), + }; + + private static PolicyRule CloneRule(PolicyRule rule) => new() + { + Id = rule.Id, + Enabled = rule.Enabled, + Priority = rule.Priority, + Decision = rule.Decision, + Reason = rule.Reason, + Match = CloneMatch(rule.Match), + Constraints = rule.Constraints is null ? null : CloneConstraints(rule.Constraints), + }; + + private static PolicyEditorDraftMatch ToDraft(PolicyMatch match) => new() + { + Operations = [.. match.Operations], + Managers = [.. match.Managers], + Sources = [.. match.Sources], + PackageIdentifiers = [.. match.PackageIdentifiers], + PackageNames = [.. match.PackageNames], + Versions = [.. match.Versions], + VersionRange = match.VersionRange is null ? null : ToDraft(match.VersionRange), + Scopes = [.. match.Scopes], + Architectures = [.. match.Architectures], + Elevation = [.. match.Elevation], + Interactive = ToTriState(match.Interactive), + SkipHashCheck = ToTriState(match.SkipHashCheck), + PreRelease = ToTriState(match.PreRelease), + HasCustomParameters = ToTriState(match.HasCustomParameters), + HasCustomInstallLocation = ToTriState(match.HasCustomInstallLocation), + HasPrePostCommands = ToTriState(match.HasPrePostCommands), + HasKillBeforeOperation = ToTriState(match.HasKillBeforeOperation), + HasUninstallPrevious = ToTriState(match.HasUninstallPrevious), + }; + + private static PolicyMatch ToDocument(PolicyEditorDraftMatch draft) => new() + { + Operations = [.. draft.Operations], + Managers = [.. draft.Managers], + Sources = [.. draft.Sources], + PackageIdentifiers = [.. draft.PackageIdentifiers], + PackageNames = [.. draft.PackageNames], + Versions = [.. draft.Versions], + VersionRange = draft.VersionRange is null ? null : ToDocument(draft.VersionRange), + Scopes = [.. draft.Scopes], + Architectures = [.. draft.Architectures], + Elevation = [.. draft.Elevation], + Interactive = FromTriState(draft.Interactive), + SkipHashCheck = FromTriState(draft.SkipHashCheck), + PreRelease = FromTriState(draft.PreRelease), + HasCustomParameters = FromTriState(draft.HasCustomParameters), + HasCustomInstallLocation = FromTriState(draft.HasCustomInstallLocation), + HasPrePostCommands = FromTriState(draft.HasPrePostCommands), + HasKillBeforeOperation = FromTriState(draft.HasKillBeforeOperation), + HasUninstallPrevious = FromTriState(draft.HasUninstallPrevious), + }; + + private static PolicyMatch CloneMatch(PolicyMatch match) => new() + { + Operations = [.. match.Operations], + Managers = [.. match.Managers], + Sources = [.. match.Sources], + PackageIdentifiers = [.. match.PackageIdentifiers], + PackageNames = [.. match.PackageNames], + Versions = [.. match.Versions], + VersionRange = match.VersionRange is null ? null : CloneVersionRange(match.VersionRange), + Scopes = [.. match.Scopes], + Architectures = [.. match.Architectures], + Elevation = [.. match.Elevation], + Interactive = [.. match.Interactive], + SkipHashCheck = [.. match.SkipHashCheck], + PreRelease = [.. match.PreRelease], + HasCustomParameters = [.. match.HasCustomParameters], + HasCustomInstallLocation = [.. match.HasCustomInstallLocation], + HasPrePostCommands = [.. match.HasPrePostCommands], + HasKillBeforeOperation = [.. match.HasKillBeforeOperation], + HasUninstallPrevious = [.. match.HasUninstallPrevious], + }; + + private static PolicyEditorDraftVersionRange ToDraft(VersionRange range) => new() + { + MinVersion = range.MinVersion, + MaxVersion = range.MaxVersion, + IncludePrerelease = range.IncludePrerelease, + }; + + private static VersionRange ToDocument(PolicyEditorDraftVersionRange draft) => new() + { + MinVersion = draft.MinVersion, + MaxVersion = draft.MaxVersion, + IncludePrerelease = draft.IncludePrerelease, + }; + + private static VersionRange CloneVersionRange(VersionRange range) => new() + { + MinVersion = range.MinVersion, + MaxVersion = range.MaxVersion, + IncludePrerelease = range.IncludePrerelease, + }; + + private static PolicyEditorDraftConstraints ToDraft(PolicyConstraints constraints) => new() + { + AllowInteractive = constraints.AllowInteractive, + AllowSkipHashCheck = constraints.AllowSkipHashCheck, + AllowPreRelease = constraints.AllowPreRelease, + AllowCustomInstallLocation = constraints.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. constraints.AllowedInstallLocationPatterns], + AllowCustomParameters = constraints.AllowCustomParameters, + AllowedCustomParameters = [.. constraints.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. constraints.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. constraints.DeniedCustomParameters], + AllowPrePostCommands = constraints.AllowPrePostCommands, + AllowKillBeforeOperation = constraints.AllowKillBeforeOperation, + AllowUninstallPrevious = constraints.AllowUninstallPrevious, + AllowUpgrade = constraints.AllowUpgrade, + }; + + private static PolicyConstraints ToDocument(PolicyEditorDraftConstraints draft) => new() + { + AllowInteractive = draft.AllowInteractive, + AllowSkipHashCheck = draft.AllowSkipHashCheck, + AllowPreRelease = draft.AllowPreRelease, + AllowCustomInstallLocation = draft.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. draft.AllowedInstallLocationPatterns], + AllowCustomParameters = draft.AllowCustomParameters, + AllowedCustomParameters = [.. draft.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. draft.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. draft.DeniedCustomParameters], + AllowPrePostCommands = draft.AllowPrePostCommands, + AllowKillBeforeOperation = draft.AllowKillBeforeOperation, + AllowUninstallPrevious = draft.AllowUninstallPrevious, + AllowUpgrade = draft.AllowUpgrade, + }; + + private static PolicyConstraints CloneConstraints(PolicyConstraints constraints) => new() + { + AllowInteractive = constraints.AllowInteractive, + AllowSkipHashCheck = constraints.AllowSkipHashCheck, + AllowPreRelease = constraints.AllowPreRelease, + AllowCustomInstallLocation = constraints.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. constraints.AllowedInstallLocationPatterns], + AllowCustomParameters = constraints.AllowCustomParameters, + AllowedCustomParameters = [.. constraints.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. constraints.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. constraints.DeniedCustomParameters], + AllowPrePostCommands = constraints.AllowPrePostCommands, + AllowKillBeforeOperation = constraints.AllowKillBeforeOperation, + AllowUninstallPrevious = constraints.AllowUninstallPrevious, + AllowUpgrade = constraints.AllowUpgrade, + }; + + // ---- Tri-state boolean-match conversion -------------------------------------------------- + + /// + /// Converts the contract's empty-or-single-value boolean match into a tri-state. + /// + internal static TriState ToTriState(IReadOnlyCollection values) => values.Count switch + { + 0 => TriState.Omitted, + 1 => values.First() ? TriState.True : TriState.False, + _ => throw new InvalidDataException("Boolean policy match arrays may contain at most one value."), + }; + + internal static List FromTriState(TriState state) => state switch + { + TriState.Omitted => [], + TriState.True => [true], + TriState.False => [false], + _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown tri-state value."), + }; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs new file mode 100644 index 0000000000..09fb796a74 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs @@ -0,0 +1,27 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Single source of truth for the parts of the package broker policy document contract that the +/// editor treats as fixed (non-negotiable) rather than user-editable. +/// +public static class PolicyEditorPolicyContract +{ + /// + /// The only value the editor understands. + /// + public const string PolicyType = "PackageBrokerPolicy"; + + /// + /// The only value the editor understands. + /// + public const RulePrecedence FixedRulePrecedence = RulePrecedence.PriorityThenDeny; + + /// + /// The fail-closed default decision applied to brand-new policy documents: deny unless a rule + /// explicitly allows the operation. + /// + public const Decision DefaultTemplateDecision = Decision.Deny; + +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs new file mode 100644 index 0000000000..26f125909b --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs @@ -0,0 +1,283 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.AgentBroker.PolicyManagement; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Production bridge from the editor-facing seam to +/// . Every +/// outcome that is not "the Agent produced a validation result" is mapped onto the narrower +/// contract via an , so +/// always has something to report instead of a silently +/// empty findings list. +/// +public sealed class BrokerPolicyEditorValidationClient : IPolicyValidationClient +{ + private readonly IBrokerPolicyManagementService _service; + + public BrokerPolicyEditorValidationClient() + : this(new BrokerPolicyManagementService()) + { + } + + public BrokerPolicyEditorValidationClient(IBrokerPolicyManagementService service) + { + _service = service; + } + + public async Task ValidateAsync(JsonElement draft, CancellationToken cancellationToken) + { + BrokerPolicyValidationOutcome outcome = await _service.ValidateAsync(draft, cancellationToken).ConfigureAwait(false); + return outcome.Status switch + { + BrokerPolicyValidationStatus.Completed when outcome.Validation is not null => + BuildCompletedOutcome(outcome), + BrokerPolicyValidationStatus.MalformedDraft => + new PolicyEditorValidationOutcome(null, ErrorCode.MalformedDraft), + BrokerPolicyValidationStatus.RequestTooLarge => + new PolicyEditorValidationOutcome(null, ErrorCode.PayloadTooLarge), + BrokerPolicyValidationStatus.AccessDenied => + new PolicyEditorValidationOutcome(null, ErrorCode.Forbidden), + BrokerPolicyValidationStatus.Unsupported => + new PolicyEditorValidationOutcome(null, ErrorCode.UnsupportedEndpoint), + _ => new PolicyEditorValidationOutcome(null, ErrorCode.InternalError), + }; + } + + private static PolicyEditorValidationOutcome BuildCompletedOutcome( + BrokerPolicyValidationOutcome outcome) + { + if (outcome.Validation is null || outcome.Diagnostics is null) + return new PolicyEditorValidationOutcome(outcome.Validation); + + IReadOnlyList findings = + [ + .. outcome.Diagnostics.Findings.Select(PolicyValidationFinding.FromSanitized), + ]; + int omitted = Math.Max( + 0, + outcome.Validation.Findings.Count - outcome.Diagnostics.Findings.Count); + if (outcome.Diagnostics.FindingsTruncated && omitted == 0) + { + omitted = 1; + } + + return new PolicyEditorValidationOutcome( + outcome.Validation, + BoundedFindings: findings, + OmittedFindingCount: omitted); + } +} + +/// +/// Production bridge from the editor-facing seam to +/// (the Windows elevated-helper write path). +/// Maps the editor's shared / +/// onto the AgentBroker package's own (structurally identical, but distinct) elevation enums, and maps +/// every onto a so the session +/// view model can present a specific, translated failure reason instead of a generic error. +/// +public sealed class WindowsPolicyEditorWriteClient : IPolicyWriteClient +{ + private static readonly TimeSpan DefaultCommittedRefreshTimeout = TimeSpan.FromSeconds(3); + + private readonly IPolicyWriteElevator _elevator; + private readonly IBrokerPolicyManagementService _managementService; + private readonly TimeSpan _committedRefreshTimeout; + + public WindowsPolicyEditorWriteClient() + : this(CreateDefaultElevator(), new BrokerPolicyManagementService()) + { + } + + public WindowsPolicyEditorWriteClient( + IPolicyWriteElevator elevator, + IBrokerPolicyManagementService? managementService = null, + TimeSpan? committedRefreshTimeout = null) + { + _elevator = elevator; + _managementService = managementService ?? new BrokerPolicyManagementService(); + _committedRefreshTimeout = committedRefreshTimeout ?? DefaultCommittedRefreshTimeout; + } + + private static IPolicyWriteElevator CreateDefaultElevator() + { +#if WINDOWS + return new WindowsPolicyWriteElevator(); +#else + return new UnsupportedPolicyWriteElevator(); +#endif + } + + public async Task WriteAsync(PolicyEditorWriteRequest request, CancellationToken cancellationToken) + { + var elevationRequest = new PolicyElevationWriteRequest( + request.Draft, + request.ExpectedStoreToken, + request.ValidationReceipt) + { + Operation = MapOperation(request.Operation), + ConflictHandling = MapConflictHandling(request.ConflictHandling), + WarningsAcknowledged = request.WarningsAcknowledged, + }; + + PolicyElevationResult result; + try + { + result = await _elevator.ReplacePolicyAsync(elevationRequest, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + + if (result.Outcome is PolicyElevationOutcome.Cancelled) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + Logger.Warn( + "[PolicyEditor] The elevation path reported cancellation without caller cancellation."); + return PolicyWriteOutcome.Failure(PolicyWriteFailureKind.ProtocolFailed); + } + + if (result.Succeeded && result.CommittedStoreToken is not null) + { + BrokerPolicyManagementResult refreshed; + using (var refreshCancellation = new CancellationTokenSource()) + { + try + { + refreshed = await _managementService + .GetManagementAsync(refreshCancellation.Token) + .WaitAsync(_committedRefreshTimeout, CancellationToken.None) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + refreshCancellation.Cancel(); + Logger.Warn( + "[PolicyEditor] The Agent committed the policy, but the authoritative management refresh timed out."); + return PolicyWriteOutcome.Failure(PolicyWriteFailureKind.WriteResultUnknown); + } + } + + if (refreshed is + { + Status: BrokerPolicyManagementStatus.Retrieved, + Snapshot: + { + State: PolicyManagementState.Active, + Policy: not null, + } snapshot, + }) + { + var response = new PolicyReplacementResponse + { + Policy = snapshot.Policy, + Management = snapshot, + }; + return PolicyWriteOutcome.Success( + response, + savedThenSuperseded: !string.Equals( + result.CommittedStoreToken, + snapshot.StoreToken, + StringComparison.Ordinal)); + } + + Logger.Warn( + "[PolicyEditor] The Agent committed the policy, but management state could not be refreshed."); + return PolicyWriteOutcome.Failure(PolicyWriteFailureKind.WriteResultUnknown); + } + + if (result.ErrorMessage is not null) + { + Logger.Warn($"[PolicyEditor] Elevated policy write did not succeed ({result.Outcome}): {result.ErrorMessage}"); + } + + ErrorCode? errorCode = TryParseErrorCode(result.BrokerErrorCode); + ErrorResponse? error = errorCode is null + ? null + : new ErrorResponse { Code = errorCode.Value }; + PolicyEditorRetryDecision? conflict = BuildConflictDecision(request, result, errorCode); + return PolicyWriteOutcome.Failure( + MapFailureKind(result.Outcome), + error, + conflict); + } + + private static PolicyWriteFailureKind MapFailureKind(PolicyElevationOutcome outcome) => outcome switch + { + PolicyElevationOutcome.Replaced => PolicyWriteFailureKind.None, + PolicyElevationOutcome.UserDeclinedElevation => PolicyWriteFailureKind.UacCanceled, + PolicyElevationOutcome.UnsupportedPlatform + or PolicyElevationOutcome.HelperUnavailable + or PolicyElevationOutcome.LaunchFailed => PolicyWriteFailureKind.LaunchFailed, + PolicyElevationOutcome.HelperUntrusted + or PolicyElevationOutcome.PeerAuthenticationFailed => PolicyWriteFailureKind.AuthenticationFailed, + PolicyElevationOutcome.PayloadTooLarge + or PolicyElevationOutcome.MalformedResponse + or PolicyElevationOutcome.TimedOut + or PolicyElevationOutcome.ConnectionClosed => PolicyWriteFailureKind.ProtocolFailed, + PolicyElevationOutcome.HelperCrashed => PolicyWriteFailureKind.HelperFailed, + PolicyElevationOutcome.BrokerRejected + or PolicyElevationOutcome.BrokerUnavailable + or PolicyElevationOutcome.BrokerInvalidResponse => PolicyWriteFailureKind.BrokerRejected, + PolicyElevationOutcome.WriteResultUnknown => PolicyWriteFailureKind.WriteResultUnknown, + PolicyElevationOutcome.Cancelled => PolicyWriteFailureKind.ProtocolFailed, + _ => PolicyWriteFailureKind.HelperFailed, + }; + + private static ErrorCode? TryParseErrorCode(string? value) => + Enum.TryParse(value, ignoreCase: false, out ErrorCode parsed) + && Enum.IsDefined(parsed) + ? parsed + : null; + + private static PolicyEditorRetryDecision? BuildConflictDecision( + PolicyEditorWriteRequest request, + PolicyElevationResult result, + ErrorCode? errorCode) + { + if (errorCode != ErrorCode.StalePolicyStoreToken + || result.ConflictStoreToken is null + || result.ConflictState is null) + { + return null; + } + + PolicyManagementState state = result.ConflictState.Value switch + { + PolicyElevationManagementState.Active => PolicyManagementState.Active, + PolicyElevationManagementState.Missing => PolicyManagementState.Missing, + PolicyElevationManagementState.Invalid => PolicyManagementState.Invalid, + _ => throw new InvalidDataException("The helper returned an invalid conflict state."), + }; + string draftId = request.Draft.GetProperty("Metadata").GetProperty("Id").GetString() + ?? throw new InvalidDataException("The validated draft did not carry an identity."); + return PolicyEditorRetryResolver.Resolve( + draftId, + state, + result.ConflictStoreToken, + result.ConflictPolicyId); + } + + private static PolicyElevationOperation MapOperation(PolicyReplacementOperation operation) => operation switch + { + PolicyReplacementOperation.Update => PolicyElevationOperation.Update, + PolicyReplacementOperation.ReplaceIdentity => PolicyElevationOperation.ReplaceIdentity, + PolicyReplacementOperation.Create => PolicyElevationOperation.Create, + PolicyReplacementOperation.Repair => PolicyElevationOperation.Repair, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null), + }; + + private static PolicyElevationConflictHandling MapConflictHandling(PolicyConflictHandling handling) => handling switch + { + PolicyConflictHandling.Reject => PolicyElevationConflictHandling.Reject, + PolicyConflictHandling.ConfirmOverwrite => PolicyElevationConflictHandling.ConfirmOverwrite, + _ => throw new ArgumentOutOfRangeException(nameof(handling), handling, null), + }; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs new file mode 100644 index 0000000000..49f08030f6 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs @@ -0,0 +1,293 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Stable, localizable classification of a raw policy-draft parsing failure. +public enum PolicyEditorSyntaxErrorKind +{ + EmptyDocument, + InvalidJson, + InvalidPolicyDraft, + LegacySchemaField, + LegacyPolicyVersionField, + MissingPolicyFormatVersion, + InvalidPolicyFormatVersion, + UnsupportedPolicyFormatVersion, + UnsupportedPolicyType, + MissingEnforcement, + UnsupportedRulePrecedence, + MissingMetadata, +} + +/// A structural failure that prevented raw JSON text from becoming a structured draft. +public sealed record PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind Kind, string Pointer); + +/// +/// The two seams between the editor's raw-text surface and its structured surface: +/// (raw -> structured, only for syntactically and structurally valid +/// text) and (structured -> raw, always succeeds). Parsing is strict and +/// fails closed: invalid JSON, JSON that doesn't match the wire shape, or JSON that disagrees with the +/// fixed policy-type/rule-precedence contract (see ) is +/// rejected outright with a and the original raw text is left +/// completely untouched by the caller (this class never mutates or truncates input). Agent-side +/// semantic validation (e.g. whether specific values make operational sense) is intentionally out of +/// scope here — it is external, see . +/// +public static partial class PolicyEditorRawSyntax +{ + public static bool TryParseStrict( + string? rawJson, + out PolicyEditorDraftDocument? draft, + out PolicyEditorSyntaxError? error) + { + return TryParseStrictWithElement(rawJson, out draft, out _, out error); + } + + public static bool TryParseStrictWithElement( + string? rawJson, + out PolicyEditorDraftDocument? draft, + out JsonElement element, + out PolicyEditorSyntaxError? error) + { + draft = null; + element = default; + error = null; + + if (string.IsNullOrWhiteSpace(rawJson)) + { + error = new PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind.EmptyDocument, ""); + return false; + } + + try + { + using JsonDocument json = JsonDocument.Parse(rawJson); + element = json.RootElement.Clone(); + if (!TryCheckDraftContractFields(json.RootElement, out error)) + { + return false; + } + } + catch (JsonException) + { + error = new PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind.InvalidJson, ""); + return false; + } + + PolicyDraftDocument? document; + try + { + document = PolicySerializer.DeserializePolicyDraftDocumentStrict(rawJson); + } + catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException or NotSupportedException) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.InvalidPolicyDraft, + PointerFromException(ex)); + return false; + } + + if (document is null) + { + error = new PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind.InvalidPolicyDraft, ""); + return false; + } + + if (!TryCheckFixedContract(document, out error)) + { + return false; + } + + draft = PolicyEditorMapper.ToDraft(document); + return true; + } + + private static bool TryCheckDraftContractFields( + JsonElement root, + out PolicyEditorSyntaxError? error) + { + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("$schema", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.LegacySchemaField, + "/$schema"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("PolicyVersion", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.LegacyPolicyVersionField, + "/PolicyVersion"); + return false; + } + + JsonElement policyFormatVersion = default; + if (root.ValueKind == JsonValueKind.Object + && !root.TryGetProperty("PolicyFormatVersion", out policyFormatVersion)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingPolicyFormatVersion, + "/PolicyFormatVersion"); + return false; + } + + if (policyFormatVersion.ValueKind == JsonValueKind.String + && !TryParsePolicyFormatVersion( + policyFormatVersion.GetString(), + out PolicyEditorSyntaxErrorKind? formatError)) + { + error = new PolicyEditorSyntaxError( + formatError!.Value, + "/PolicyFormatVersion"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("PolicyType", out JsonElement policyType) + && policyType.ValueKind == JsonValueKind.String + && !string.Equals( + policyType.GetString(), + PolicyEditorPolicyContract.PolicyType, + StringComparison.Ordinal)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.UnsupportedPolicyType, + "/PolicyType"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && !root.TryGetProperty("Enforcement", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingEnforcement, + "/Enforcement"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("Enforcement", out JsonElement enforcement) + && enforcement.ValueKind == JsonValueKind.Object + && enforcement.TryGetProperty("RulePrecedence", out JsonElement precedence) + && precedence.ValueKind == JsonValueKind.String + && !string.Equals( + precedence.GetString(), + PolicyEditorPolicyContract.FixedRulePrecedence.ToString(), + StringComparison.Ordinal)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.UnsupportedRulePrecedence, + "/Enforcement/RulePrecedence"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && !root.TryGetProperty("Metadata", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingMetadata, + "/Metadata"); + return false; + } + + error = null; + return true; + } + + /// + /// Serializes exactly the editable draft shape. Server-managed metadata is never emitted. + /// + public static string ToCanonicalRaw(PolicyEditorDraftDocument draft) => + PolicySerializer.Serialize(PolicyEditorMapper.ToSharedDraft(draft)); + + private static bool TryCheckFixedContract(PolicyDraftDocument document, out PolicyEditorSyntaxError? error) + { + if (!string.Equals(document.PolicyType, PolicyEditorPolicyContract.PolicyType, StringComparison.Ordinal)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.UnsupportedPolicyType, + "/PolicyType"); + return false; + } + + if (document.Enforcement is null) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingEnforcement, + "/Enforcement"); + return false; + } + + if (document.Enforcement.RulePrecedence != PolicyEditorPolicyContract.FixedRulePrecedence) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.UnsupportedRulePrecedence, + "/Enforcement/RulePrecedence"); + return false; + } + + if (document.Metadata is null) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingMetadata, + "/Metadata"); + return false; + } + + error = null; + return true; + } + + private static bool TryParsePolicyFormatVersion( + string? value, + out PolicyEditorSyntaxErrorKind? error) + { + try + { + PolicyFormatVersion.Parse(value!); + error = null; + return true; + } + catch (FormatException) + { + error = PolicyEditorSyntaxErrorKind.InvalidPolicyFormatVersion; + return false; + } + catch (NotSupportedException) + { + error = PolicyEditorSyntaxErrorKind.UnsupportedPolicyFormatVersion; + return false; + } + catch (ArgumentNullException) + { + error = PolicyEditorSyntaxErrorKind.InvalidPolicyFormatVersion; + return false; + } + } + + private static string PointerFromException(Exception ex) => + ex is JsonException { Path: { Length: > 0 } path } ? ConvertJsonPathToPointer(path) : ""; + + /// Converts a System.Text.Json exception path (e.g. $.rules[0].match.versions[1]) + /// into an RFC 6901 JSON Pointer (e.g. /rules/0/match/versions/1). + private static string ConvertJsonPathToPointer(string path) + { + StringBuilder builder = new(); + foreach (Match match in JsonPathSegment().Matches(path)) + { + string segment = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + builder.Append('/').Append(segment.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal)); + } + + return builder.ToString(); + } + + [GeneratedRegex(@"\.([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]", RegexOptions.CultureInvariant)] + private static partial Regex JsonPathSegment(); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs new file mode 100644 index 0000000000..033f90bd89 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs @@ -0,0 +1,86 @@ +using Devolutions.Now.Policy.Api; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public sealed record PolicyEditorRetryDecision( + PolicyReplacementOperation Operation, + string Token, + PolicyManagementState State, + string? ActivePolicyId); + +public sealed record PolicyEditorConfirmationContext( + PolicyReplacementOperation Operation, + PolicyManagementState State, + string? ActivePolicyId, + string Token, + string DraftId) +{ + public static PolicyEditorConfirmationContext For( + PolicyEditorRetryDecision decision, + string draftId) => + new(decision.Operation, decision.State, decision.ActivePolicyId, decision.Token, draftId); +} + +public static class PolicyEditorRetryResolver +{ + public static PolicyEditorRetryDecision Resolve( + string draftId, + PolicyManagementSnapshot management) + { + ArgumentNullException.ThrowIfNull(management); + return Resolve( + draftId, + management.State, + management.StoreToken, + management.Policy?.Metadata.Id); + } + + public static PolicyEditorRetryDecision Resolve( + string draftId, + PolicyManagementState state, + string token, + string? activePolicyId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + ArgumentException.ThrowIfNullOrWhiteSpace(token); + return state switch + { + PolicyManagementState.Active when activePolicyId is not null + && string.Equals( + activePolicyId, + draftId, + StringComparison.Ordinal) => + new( + PolicyReplacementOperation.Update, + token, + state, + activePolicyId), + PolicyManagementState.Active when activePolicyId is not null => + new( + PolicyReplacementOperation.ReplaceIdentity, + token, + state, + activePolicyId), + PolicyManagementState.Missing => + new( + PolicyReplacementOperation.Create, + token, + state, + null), + PolicyManagementState.Invalid => + new( + PolicyReplacementOperation.Repair, + token, + state, + null), + _ => throw new InvalidDataException( + "The management snapshot is inconsistent with its policy state."), + }; + } + + public static bool RequiresFreshConfirmation( + PolicyEditorConfirmationContext? existing, + PolicyEditorRetryDecision decision, + string draftId) => + existing != PolicyEditorConfirmationContext.For(decision, draftId); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs new file mode 100644 index 0000000000..ef5919068d --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs @@ -0,0 +1,713 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public sealed record PolicyEditorValidationState( + string SubmittedRawJson, + PolicyDraftDocument CanonicalDraft, + string Receipt, + PolicyEditorFindingIndex Findings, + int WarningCount) +{ + public bool HasWarnings => WarningCount > 0; +} + +public sealed record PolicyEditorWarningAcknowledgement( + string CanonicalRawJson, + string Receipt, + int WarningCount, + IReadOnlyList WarningKeys); + +public sealed record PolicyEditorConflictSnapshot( + string SubmittedCanonicalRawJson, + string ValidationReceipt, + string DraftId, + long MutationGeneration, + PolicyEditorRetryDecision RetryDecision, + DateTimeOffset DetectedAt); + +internal readonly record struct PolicyEditorDirtyComparisonSnapshot( + long MutationGeneration, + long BaselineVersion, + string BaselineRawJson); + +public sealed class PolicyEditorSession +{ + private string _baselineRawJson; + private string _lastAnalyzedRawJson; + private string _lastAnalyzedCanonicalRawJson; + private string _lastAnalyzedDraftId; + private JsonElement _lastAnalyzedRawElement; + private bool _hasLastAnalyzedRawElement; + private long _lastAnalyzedMutationGeneration; + private long _mutationGeneration; + private long _cleanMutationGeneration; + private long _baselineVersion; + private bool _isDirty; + + public PolicyEditorOperationKind Operation { get; private set; } + + public PolicyManagementSnapshot OriginManagement { get; private set; } + + public PolicyEditorDraftDocument Draft { get; private set; } + + public string RawBuffer { get; private set; } + + public PolicyEditorMode Mode { get; private set; } + + public PolicyEditorValidationState? Validation { get; private set; } + + public PolicyEditorFindingIndex Findings { get; private set; } = + PolicyEditorFindingIndex.Build([]); + + public PolicyEditorWarningAcknowledgement? WarningAcknowledgement { get; private set; } + + public PolicyEditorConflictSnapshot? Conflict { get; private set; } + + public long MutationGeneration => _mutationGeneration; + + public bool IsRawAnalysisPending { get; private set; } + + public bool IsIdentityLocked => Operation == PolicyEditorOperationKind.Update; + + public bool IsDirty => _isDirty; + + public bool IsValidationCurrent => + Validation is not null + && !IsRawAnalysisPending + && string.Equals( + Validation.SubmittedRawJson, + GetEffectiveRawJson(), + StringComparison.Ordinal); + + public bool HasCurrentWarningAcknowledgement + { + get + { + if (Validation is null || WarningAcknowledgement is null) + return false; + + string canonical = PolicySerializer.Serialize(Validation.CanonicalDraft); + return string.Equals( + WarningAcknowledgement.CanonicalRawJson, + canonical, + StringComparison.Ordinal) + && string.Equals( + WarningAcknowledgement.Receipt, + Validation.Receipt, + StringComparison.Ordinal) + && WarningAcknowledgement.WarningCount == Validation.WarningCount + && WarningAcknowledgement.WarningKeys.SequenceEqual( + GetWarningKeys(Validation.Findings), + StringComparer.Ordinal); + } + } + + private PolicyEditorSession( + PolicyEditorOperationKind operation, + PolicyManagementSnapshot originManagement, + PolicyEditorDraftDocument draft) + { + ArgumentException.ThrowIfNullOrWhiteSpace(originManagement.StoreToken); + Operation = operation; + OriginManagement = PolicyEditorMapper.CloneManagementSnapshot(originManagement); + Draft = draft.Clone(); + Mode = PolicyEditorMode.Structured; + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + _baselineRawJson = RawBuffer; + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _cleanMutationGeneration = _mutationGeneration; + } + + public static PolicyEditorSession StartUpdate(PolicyManagementSnapshot management) + { + RequireState(management, PolicyManagementState.Active); + return new( + PolicyEditorOperationKind.Update, + management, + PolicyEditorMapper.ToDraft(management.Policy!)); + } + + public static PolicyEditorSession StartReplaceIdentity( + PolicyManagementSnapshot management, + PolicyEditorDraftDocument draft) + { + RequireState(management, PolicyManagementState.Active); + if (string.Equals( + management.Policy!.Metadata.Id, + draft.Metadata.Id, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "A replacement policy must use a different identity.", + nameof(draft)); + } + + return new(PolicyEditorOperationKind.ReplaceIdentity, management, draft); + } + + public static PolicyEditorSession StartCreate( + PolicyManagementSnapshot management, + PolicyEditorDraftDocument draft) + { + RequireState(management, PolicyManagementState.Missing); + return new(PolicyEditorOperationKind.Create, management, draft); + } + + public static PolicyEditorSession StartRepair( + PolicyManagementSnapshot management, + PolicyEditorDraftDocument draft) + { + RequireState(management, PolicyManagementState.Invalid); + return new(PolicyEditorOperationKind.Repair, management, draft); + } + + public void SwitchToRaw() + { + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + Mode = PolicyEditorMode.Raw; + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + IsRawAnalysisPending = false; + InvalidateContentState(markDirty: false); + _isDirty = !string.Equals(RawBuffer, _baselineRawJson, StringComparison.Ordinal); + if (!_isDirty) + _cleanMutationGeneration = _mutationGeneration; + _lastAnalyzedMutationGeneration = _mutationGeneration; + } + + public void SetRawBuffer(string rawText) + { + if (Mode != PolicyEditorMode.Raw) + throw new InvalidOperationException("The session is not in raw mode."); + + RawBuffer = rawText ?? ""; + _mutationGeneration++; + _isDirty = true; + IsRawAnalysisPending = true; + } + + public bool CompleteRawAnalysis( + string analyzedRawJson, + long analyzedMutationGeneration, + string? canonicalRawJson, + string? draftId, + JsonElement? rawElement) + { + if (Mode != PolicyEditorMode.Raw + || analyzedMutationGeneration != _mutationGeneration + || !string.Equals(RawBuffer, analyzedRawJson, StringComparison.Ordinal)) + { + return false; + } + + IsRawAnalysisPending = false; + _hasLastAnalyzedRawElement = rawElement.HasValue; + _lastAnalyzedRawElement = rawElement?.Clone() ?? default; + _lastAnalyzedMutationGeneration = analyzedMutationGeneration; + bool formattingOnly = + canonicalRawJson is not null + && string.Equals( + _lastAnalyzedCanonicalRawJson, + canonicalRawJson, + StringComparison.Ordinal); + if (formattingOnly) + { + if (Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + _lastAnalyzedRawJson, + StringComparison.Ordinal)) + { + Validation = Validation with { SubmittedRawJson = RawBuffer }; + } + if (Conflict is not null) + { + Conflict = Conflict with { MutationGeneration = _mutationGeneration }; + } + _lastAnalyzedRawJson = RawBuffer; + return true; + } + + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = canonicalRawJson ?? ""; + _lastAnalyzedDraftId = draftId ?? ""; + ClearContentState(); + return true; + } + + public bool TryGetAnalyzedRawElement(out JsonElement element) + { + if (Mode == PolicyEditorMode.Raw + && !IsRawAnalysisPending + && _hasLastAnalyzedRawElement + && _lastAnalyzedMutationGeneration == _mutationGeneration + && string.Equals( + _lastAnalyzedRawJson, + RawBuffer, + StringComparison.Ordinal)) + { + element = _lastAnalyzedRawElement.Clone(); + return true; + } + + element = default; + return false; + } + + public bool TryParseRaw( + out PolicyEditorDraftDocument? parsed, + out PolicyEditorSyntaxError? error) => + PolicyEditorRawSyntax.TryParseStrict(RawBuffer, out parsed, out error); + + public void AcceptValidatedRaw( + string submittedRawJson, + PolicyValidationResult validation) + { + ApplyValidationResult(submittedRawJson, validation); + if (Validation is null) + throw new InvalidOperationException( + "Only an authoritative valid result can enter structured mode."); + + Draft = PolicyEditorMapper.ToDraft(Validation.CanonicalDraft); + RawBuffer = PolicySerializer.Serialize(Validation.CanonicalDraft); + Mode = PolicyEditorMode.Structured; + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _hasLastAnalyzedRawElement = false; + IsRawAnalysisPending = false; + _isDirty = !string.Equals(RawBuffer, _baselineRawJson, StringComparison.Ordinal); + if (!_isDirty) + _cleanMutationGeneration = _mutationGeneration; + } + + public string GetEffectiveRawJson() => + Mode == PolicyEditorMode.Raw + ? RawBuffer + : PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + + public void NotifyDraftChanged() => InvalidateContentState(); + + public PolicyEditorDraftRule AddRule(PolicyEditorDraftRule? rule = null) + { + EnsureStructuredMode(); + PolicyEditorDraftRule newRule = rule ?? PolicyRuleFactory.CreateBlank(); + PolicyRuleListOperations.Add(Draft.Rules, newRule); + InvalidateContentState(); + return newRule; + } + + public void EditRule(string id, Action mutate) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Edit(Draft.Rules, id, mutate); + InvalidateContentState(); + } + + public string DuplicateRule(string id, string? newId = null) + { + EnsureStructuredMode(); + string result = PolicyRuleListOperations.Duplicate(Draft.Rules, id, newId); + InvalidateContentState(); + return result; + } + + public string DuplicateRule(PolicyEditorDraftRule rule, string? newId = null) + { + EnsureStructuredMode(); + string result = PolicyRuleListOperations.Duplicate(Draft.Rules, rule, newId); + InvalidateContentState(); + return result; + } + + public void SetRuleEnabled(string id, bool enabled) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetEnabled(Draft.Rules, id, enabled); + InvalidateContentState(); + } + + public void SetRuleEnabled(PolicyEditorDraftRule rule, bool enabled) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetEnabled(Draft.Rules, rule, enabled); + InvalidateContentState(); + } + + public void DeleteRule(string id) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Delete(Draft.Rules, id); + InvalidateContentState(); + } + + public void DeleteRule(PolicyEditorDraftRule rule) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Delete(Draft.Rules, rule); + InvalidateContentState(); + } + + public void MoveRule(string id, int newIndex) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Move(Draft.Rules, id, newIndex); + InvalidateContentState(); + } + + public void MoveRule(PolicyEditorDraftRule rule, int newIndex) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Move(Draft.Rules, rule, newIndex); + InvalidateContentState(); + } + + public void SetRulePriority(string id, uint priority) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetPriority(Draft.Rules, id, priority); + InvalidateContentState(); + } + + public void ApplyValidationResult( + string submittedRawJson, + PolicyValidationResult validation, + IReadOnlyList? boundedFindings = null, + int omittedFindingCount = 0) + { + ArgumentNullException.ThrowIfNull(submittedRawJson); + ArgumentNullException.ThrowIfNull(validation); + + if (boundedFindings is not null) + { + Findings = PolicyEditorFindingIndex.Build(boundedFindings, omittedFindingCount); + } + else + { + int take = Math.Min( + validation.Findings.Count, + PolicyEditorFindingIndex.MaxDisplayedFindings); + var sanitized = new List(take); + for (int index = 0; index < take; index++) + { + sanitized.Add(PolicyValidationFinding.FromShared(validation.Findings[index])); + } + + Findings = PolicyEditorFindingIndex.Build( + sanitized, + validation.Findings.Count - take); + } + WarningAcknowledgement = null; + + if (!validation.IsValid + || validation.CanonicalDraft is null + || string.IsNullOrWhiteSpace(validation.ValidationReceipt)) + { + Validation = null; + return; + } + + Validation = new PolicyEditorValidationState( + submittedRawJson, + PolicyEditorMapper.CloneDraftDocument(validation.CanonicalDraft), + validation.ValidationReceipt, + Findings, + validation.Findings.Count( + finding => finding.Severity == PolicyFindingSeverity.Warning)); + Operation = ResolveOperationForDraftId(validation.CanonicalDraft.Metadata.Id); + } + + public void AcknowledgeWarnings() + { + if (Validation is null || !Validation.HasWarnings) + throw new InvalidOperationException("There are no current validated warnings."); + + WarningAcknowledgement = new PolicyEditorWarningAcknowledgement( + PolicySerializer.Serialize(Validation.CanonicalDraft), + Validation.Receipt, + Validation.WarningCount, + GetWarningKeys(Validation.Findings)); + } + + public void CaptureConflict( + PolicyManagementSnapshot management, + PolicyDraftDocument submittedCanonicalDraft, + string validationReceipt, + string draftId) + { + ArgumentNullException.ThrowIfNull(submittedCanonicalDraft); + ArgumentException.ThrowIfNullOrWhiteSpace(validationReceipt); + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + string submittedCanonicalRawJson = PolicySerializer.Serialize(submittedCanonicalDraft); + PolicyEditorRetryDecision decision = + PolicyEditorRetryResolver.Resolve(draftId, management); + Conflict = new PolicyEditorConflictSnapshot( + submittedCanonicalRawJson, + validationReceipt, + draftId, + _mutationGeneration, + decision, + DateTimeOffset.UtcNow); + } + + public void CaptureConflict( + PolicyEditorRetryDecision decision, + PolicyDraftDocument submittedCanonicalDraft, + string validationReceipt, + string draftId) + { + ArgumentNullException.ThrowIfNull(decision); + ArgumentNullException.ThrowIfNull(submittedCanonicalDraft); + ArgumentException.ThrowIfNullOrWhiteSpace(validationReceipt); + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + Conflict = new PolicyEditorConflictSnapshot( + PolicySerializer.Serialize(submittedCanonicalDraft), + validationReceipt, + draftId, + _mutationGeneration, + decision, + DateTimeOffset.UtcNow); + } + + public void ClearConflict() => Conflict = null; + + public bool IsConflictCurrent(PolicyEditorConflictSnapshot conflict) + { + ArgumentNullException.ThrowIfNull(conflict); + if (!ReferenceEquals(Conflict, conflict) + || conflict.MutationGeneration != _mutationGeneration) + { + return false; + } + + return TryGetCanonicalEffectiveRaw(out string? canonical, out string? draftId) + && string.Equals( + canonical, + conflict.SubmittedCanonicalRawJson, + StringComparison.Ordinal) + && string.Equals(draftId, conflict.DraftId, StringComparison.Ordinal); + } + + public void MarkSaved(PolicyReplacementResponse response) + { + ArgumentNullException.ThrowIfNull(response); + if (response.Management.State != PolicyManagementState.Active + || response.Management.Policy is null + || string.IsNullOrWhiteSpace(response.Management.StoreToken)) + { + throw new InvalidDataException( + "A successful replacement did not return an active management snapshot."); + } + + PolicyDocument authoritative = PolicyEditorMapper.CloneDocument(response.Policy); + OriginManagement = PolicyEditorMapper.CloneManagementSnapshot(response.Management); + Operation = PolicyEditorOperationKind.Update; + Draft = PolicyEditorMapper.ToDraft(authoritative); + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + Mode = PolicyEditorMode.Structured; + SetCleanBaseline(RawBuffer, _mutationGeneration); + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _hasLastAnalyzedRawElement = false; + IsRawAnalysisPending = false; + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + WarningAcknowledgement = null; + Conflict = null; + } + + public void MarkSavedPreservingCurrentDraft( + PolicyReplacementResponse response, + long savedMutationGeneration) + { + ArgumentNullException.ThrowIfNull(response); + if (response.Management.State != PolicyManagementState.Active + || response.Management.Policy is null + || string.IsNullOrWhiteSpace(response.Management.StoreToken)) + { + throw new InvalidDataException( + "A successful replacement did not return an active management snapshot."); + } + + OriginManagement = PolicyEditorMapper.CloneManagementSnapshot(response.Management); + Operation = TryGetCanonicalEffectiveRaw(out _, out string? currentDraftId) + ? ResolveOperationForDraftId(currentDraftId!) + : PolicyEditorOperationKind.Update; + SetBaseline( + PolicyEditorRawSyntax.ToCanonicalRaw(PolicyEditorMapper.ToDraft(response.Policy)), + savedMutationGeneration); + _isDirty = _mutationGeneration != _cleanMutationGeneration; + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + WarningAcknowledgement = null; + Conflict = null; + } + + public PolicyEditorOperationKind ResolveOperationForDraftId(string draftId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + return OriginManagement.State switch + { + PolicyManagementState.Active + when string.Equals( + OriginManagement.Policy!.Metadata.Id, + draftId, + StringComparison.Ordinal) => + PolicyEditorOperationKind.Update, + PolicyManagementState.Active => PolicyEditorOperationKind.ReplaceIdentity, + PolicyManagementState.Missing => PolicyEditorOperationKind.Create, + PolicyManagementState.Invalid => PolicyEditorOperationKind.Repair, + _ => throw new InvalidDataException("The policy management state is not supported."), + }; + } + + internal PolicyEditorDirtyComparisonSnapshot CaptureDirtyComparison() => + new(_mutationGeneration, _baselineVersion, _baselineRawJson); + + internal bool TryApplyDirtyComparison( + PolicyEditorDirtyComparisonSnapshot snapshot, + bool isDirty) + { + if (snapshot.MutationGeneration != _mutationGeneration + || snapshot.BaselineVersion != _baselineVersion) + { + return false; + } + + _isDirty = isDirty; + if (!isDirty) + _cleanMutationGeneration = _mutationGeneration; + return true; + } + + private void InvalidateContentState(bool markDirty = true) + { + _mutationGeneration++; + if (markDirty) + _isDirty = true; + ClearContentState(); + } + + private void SetCleanBaseline(string baselineRawJson, long mutationGeneration) + { + SetBaseline(baselineRawJson, mutationGeneration); + _isDirty = false; + } + + private void SetBaseline(string baselineRawJson, long mutationGeneration) + { + _baselineRawJson = baselineRawJson; + _cleanMutationGeneration = mutationGeneration; + _baselineVersion++; + } + + private void ClearContentState() + { + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + WarningAcknowledgement = null; + Conflict = null; + } + + private bool TryGetCanonicalEffectiveRaw( + out string? canonicalRawJson, + out string? draftId) + { + if (Mode == PolicyEditorMode.Raw) + { + if (IsRawAnalysisPending + || _lastAnalyzedMutationGeneration != _mutationGeneration + || !string.Equals( + _lastAnalyzedRawJson, + RawBuffer, + StringComparison.Ordinal) + || string.IsNullOrEmpty(_lastAnalyzedCanonicalRawJson)) + { + canonicalRawJson = null; + draftId = null; + return false; + } + + canonicalRawJson = _lastAnalyzedCanonicalRawJson; + draftId = _lastAnalyzedDraftId; + return true; + } + + string effectiveRawJson = GetEffectiveRawJson(); + if (Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + effectiveRawJson, + StringComparison.Ordinal)) + { + canonicalRawJson = PolicySerializer.Serialize(Validation.CanonicalDraft); + draftId = Validation.CanonicalDraft.Metadata.Id; + return true; + } + + return TryCanonicalizeRaw(effectiveRawJson, out canonicalRawJson, out draftId); + } + + private static bool TryCanonicalizeRaw( + string rawJson, + out string? canonicalRawJson, + out string? draftId) + { + canonicalRawJson = null; + draftId = null; + if (!PolicyEditorRawSyntax.TryParseStrict( + rawJson, + out PolicyEditorDraftDocument? parsed, + out _) + || parsed is null) + { + return false; + } + + PolicyDraftDocument shared = PolicyEditorMapper.ToSharedDraft(parsed); + canonicalRawJson = PolicySerializer.Serialize(shared); + draftId = shared.Metadata.Id; + return true; + } + + private void EnsureStructuredMode() + { + if (Mode != PolicyEditorMode.Structured) + throw new InvalidOperationException("Rule edits require structured mode."); + } + + private static IReadOnlyList GetWarningKeys( + PolicyEditorFindingIndex findings) => + findings.All + .Where(finding => finding.Severity == PolicyValidationSeverity.Warning) + .Select(finding => + $"{finding.Code}\u001f{finding.Pointer}\u001f{finding.RuleId}") + .Order(StringComparer.Ordinal) + .ToArray(); + + private static void RequireState( + PolicyManagementSnapshot management, + PolicyManagementState expected) + { + ArgumentNullException.ThrowIfNull(management); + ArgumentException.ThrowIfNullOrWhiteSpace(management.StoreToken); + if (management.State != expected) + throw new ArgumentException( + $"Expected a {expected} management snapshot.", + nameof(management)); + if (expected == PolicyManagementState.Active && management.Policy is null) + throw new ArgumentException( + "An active management snapshot requires a policy.", + nameof(management)); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs new file mode 100644 index 0000000000..da8d3a1a55 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs @@ -0,0 +1,95 @@ +using Avalonia.Automation; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Shared "can we close/navigate away now" guard for every surface that hosts a +/// (the modal PolicyEditorDialog window's own +/// Closing event, and AgentPolicyInspector's IAsyncLeaveGuard.CanLeaveAsync for +/// page navigation/app shutdown while the dialog is open). +/// +/// +/// Blocker 31: closing/navigating/quitting while a remote policy operation +/// (validate/save/overwrite/raw-validation, and transitively the elevated-helper write exchange) is +/// in flight must first try to cancel that operation and wait a bounded amount of time for +/// it to actually settle, instead of either abruptly tearing down the session mid-flight or leaving +/// the caller no better off than an unconditional refusal. If the in-flight operation does not settle +/// within (e.g. the elevated helper is unresponsive), the guard +/// reports failure so the caller can refuse the close/leave with an accessible busy status instead of +/// silently discarding a session a command could still be mutating. +/// +public static class PolicyEditorSessionCloseGuard +{ + /// + /// How long to wait for a canceled in-flight operation to actually observe the cancellation and + /// unwind (release ) before giving up and treating + /// the session as still busy. + /// + public static readonly TimeSpan DefaultCancelWaitTimeout = TimeSpan.FromSeconds(5); + + /// + /// If currently has a remote operation in flight, requests its + /// cancellation and waits up to for it to settle. + /// + /// + /// if no operation was running, or the running operation settled within + /// ; if it is still running once the bound + /// elapses (the caller must refuse to close/leave and must not dispose or clear the session). + /// + public static async Task TryCancelActiveOperationAsync( + PolicyEditorSessionViewModel session, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + IAsyncRelayCommand? running = FindRunningCommand(session); + if (running is null) + return true; + + if (running.CanBeCanceled) + { + running.Cancel(); + } + + Task? executionTask = running.ExecutionTask; + if (executionTask is null || executionTask.IsCompleted) + return true; + + Task delay = Task.Delay(timeout, cancellationToken); + Task completed = await Task.WhenAny(executionTask, delay).ConfigureAwait(false); + if (completed != executionTask) + { + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + + // Accessing Exception observes a fault without turning close handling into an error sink. + _ = executionTask.Exception; + + return true; + } + + private static IAsyncRelayCommand? FindRunningCommand(PolicyEditorSessionViewModel session) + { + if (session.ValidateCommand.IsRunning) return session.ValidateCommand; + if (session.SaveCommand.IsRunning) return session.SaveCommand; + if (session.ConfirmOverwriteCommand.IsRunning) return session.ConfirmOverwriteCommand; + if (session.SwitchToStructuredCommand.IsRunning) return session.SwitchToStructuredCommand; + return null; + } + + /// + /// Announces (accessibly, via ) that a close or + /// navigate-away request was refused because the in-flight policy operation could not be + /// canceled within . Call this whenever + /// returns . + /// + public static void AnnounceCloseBlockedByBusyOperation() + { + AccessibilityAnnouncementService.Announce( + CoreTools.Translate("The current policy operation could not be canceled in time. Please wait, then try closing again."), + AutomationLiveSetting.Assertive); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs new file mode 100644 index 0000000000..8e191c02af --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs @@ -0,0 +1,1092 @@ +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public partial class PolicyEditorSessionViewModel : ViewModelBase, IDisposable +{ + private readonly IPolicyValidationClient _validationClient; + private readonly IPolicyEditorConfirmationPrompt _confirmationPrompt; + private readonly IPolicyWriteClient _writeClient; + private readonly TimeSpan _rawSyntaxDebounce; + private readonly TimeSpan _structuredDirtyDebounce; + private readonly Func _structuredDraftSerializer; + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly object _discardConfirmationLock = new(); + private readonly Dictionary _localInputErrors = []; + private CancellationTokenSource? _rawSyntaxCancellation; + private Task _rawSyntaxAnalysis = Task.CompletedTask; + private CancellationTokenSource? _structuredDirtyCancellation; + private Task _structuredDirtyAnalysis = Task.CompletedTask; + private Task? _discardConfirmationTask; + private long _validationGeneration; + private long _saveGeneration; + private int _isDisposed; + + public PolicyEditorSession Session { get; } + + public PolicyEditorDraftDocument Draft => Session.Draft; + public IReadOnlyList Rules => Session.Draft.Rules; + public PolicyEditorOperationKind Operation => Session.Operation; + public bool IsStructuredMode => Session.Mode == PolicyEditorMode.Structured; + public bool IsRawMode => Session.Mode == PolicyEditorMode.Raw; + public bool IsDirty => Session.IsDirty || HasLocalInputErrors; + public bool IsIdentityLocked => Session.IsIdentityLocked; + public bool HasFindings => Session.Findings.All.Count > 0; + public bool HasConflict => Session.Conflict is not null; + public IReadOnlyList Findings => Session.Findings.All; + public bool IsRawSyntaxPending => Session.IsRawAnalysisPending; + public string SyntaxErrorTitle => SyntaxError?.Kind switch + { + PolicyEditorSyntaxErrorKind.EmptyDocument or PolicyEditorSyntaxErrorKind.InvalidJson => + CoreTools.Translate("The document is not valid JSON"), + _ => CoreTools.Translate("The document is not a valid policy draft"), + }; + public string SyntaxErrorMessage => SyntaxError?.Kind switch + { + PolicyEditorSyntaxErrorKind.EmptyDocument => + CoreTools.Translate("The document is empty."), + PolicyEditorSyntaxErrorKind.InvalidJson => + CoreTools.Translate("The JSON syntax is invalid."), + PolicyEditorSyntaxErrorKind.LegacySchemaField => + CoreTools.Translate("The $schema field is obsolete. Remove it."), + PolicyEditorSyntaxErrorKind.LegacyPolicyVersionField => + CoreTools.Translate("PolicyVersion is obsolete. Rename it to PolicyFormatVersion."), + PolicyEditorSyntaxErrorKind.MissingPolicyFormatVersion => + CoreTools.Translate("The policy draft is missing PolicyFormatVersion."), + PolicyEditorSyntaxErrorKind.InvalidPolicyFormatVersion => + CoreTools.Translate("PolicyFormatVersion must be a canonical three-part numeric version such as 1.0.0."), + PolicyEditorSyntaxErrorKind.UnsupportedPolicyFormatVersion => + CoreTools.Translate("The policy draft uses an unsupported policy format version. This version supports major version 1."), + PolicyEditorSyntaxErrorKind.UnsupportedPolicyType => + CoreTools.Translate("The policy draft uses an unsupported policy type."), + PolicyEditorSyntaxErrorKind.MissingEnforcement => + CoreTools.Translate("The policy draft is missing the Enforcement object."), + PolicyEditorSyntaxErrorKind.UnsupportedRulePrecedence => + CoreTools.Translate("The policy draft uses an unsupported rule precedence."), + PolicyEditorSyntaxErrorKind.MissingMetadata => + CoreTools.Translate("The policy draft is missing the Metadata object."), + _ => CoreTools.Translate("The document does not match the policy draft format."), + }; + public bool HasLocalInputErrors => _localInputErrors.Count > 0; + public string LocalInputErrorSummary => string.Join(Environment.NewLine, _localInputErrors.Values); + public bool CanValidateOrSave => CanStartRemoteOperation(); + public bool CanSwitchToRaw => CanStartStructuredOperation(); + + public string RawBuffer + { + get => Session.RawBuffer; + set + { + if (Session.Mode != PolicyEditorMode.Raw + || string.Equals(Session.RawBuffer, value, StringComparison.Ordinal)) + return; + + Session.SetRawBuffer(value); + SyntaxError = null; + ScheduleRawSyntaxAnalysis(value); + OnEditorStateChanged(); + } + } + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private PolicyEditorSyntaxError? _syntaxError; + [ObservableProperty] private bool _lastSaveSucceeded; + [ObservableProperty] private bool _savedWithNewerChanges; + [ObservableProperty] private bool _savedThenSuperseded; + [ObservableProperty] private bool _requiresManagementRefresh; + [ObservableProperty] private ErrorCode? _lastErrorCode; + [ObservableProperty] private PolicyWriteFailureKind _lastWriteFailureKind; + internal PolicyEditorWriteCompletion? LastWriteCompletion { get; private set; } + + public PolicyEditorSessionViewModel( + PolicyEditorSession session, + IPolicyValidationClient validationClient, + IPolicyEditorConfirmationPrompt confirmationPrompt, + IPolicyWriteClient writeClient, + TimeSpan? rawSyntaxDebounce = null, + TimeSpan? structuredDirtyDebounce = null, + Func? structuredDraftSerializer = null) + { + Session = session; + _validationClient = validationClient; + _confirmationPrompt = confirmationPrompt; + _writeClient = writeClient; + _rawSyntaxDebounce = rawSyntaxDebounce ?? TimeSpan.FromMilliseconds(300); + _structuredDirtyDebounce = structuredDirtyDebounce ?? TimeSpan.FromMilliseconds(300); + _structuredDraftSerializer = + structuredDraftSerializer ?? PolicyEditorRawSyntax.ToCanonicalRaw; + } + + [RelayCommand(CanExecute = nameof(CanStartStructuredOperation))] + private void SwitchToRaw() + { + Session.SwitchToRaw(); + CancelStructuredDirtyAnalysis(); + CancelRawSyntaxAnalysis(); + SyntaxError = null; + OnEditorStateChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task SwitchToStructuredAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + string submitted = Session.RawBuffer; + if (!TryGetDraftElement( + submitted, + out JsonElement draft, + out PolicyEditorSyntaxError? syntaxError)) + { + SyntaxError = syntaxError; + return; + } + + long generation = Interlocked.Increment(ref _validationGeneration); + PolicyEditorValidationOutcome outcome = + await ValidateCoreAsync(draft, cancellationToken); + if (!CanApply(cancellationToken) + || generation != Volatile.Read(ref _validationGeneration) + || !string.Equals(Session.RawBuffer, submitted, StringComparison.Ordinal)) + return; + + if (outcome.Validation is not { IsValid: true, CanonicalDraft: not null }) + { + if (outcome.Validation is not null) + Session.ApplyValidationResult( + submitted, + outcome.Validation, + outcome.BoundedFindings, + outcome.OmittedFindingCount); + LastErrorCode = outcome.ErrorCode; + OnEditorStateChanged(); + return; + } + + Session.AcceptValidatedRaw(submitted, outcome.Validation); + SyntaxError = null; + LastErrorCode = null; + OnEditorStateChanged(); + } + + [RelayCommand] + private void NotifyDraftChanged() + { + if (Session.IsIdentityLocked + && Session.OriginManagement.Policy is { } origin + && !string.Equals( + Session.Draft.Metadata.Id, + origin.Metadata.Id, + StringComparison.Ordinal)) + { + Session.Draft.Metadata.Id = origin.Metadata.Id; + } + + Session.NotifyDraftChanged(); + OnStructuredDraftChanged(); + } + + public void NotifyLocalInputChanged() + { + Session.NotifyDraftChanged(); + OnStructuredDraftChanged(); + } + + public void SetLocalInputError(object key, string? message) + { + ArgumentNullException.ThrowIfNull(key); + if (string.IsNullOrEmpty(message)) + _localInputErrors.Remove(key); + else + _localInputErrors[key] = message; + + OnPropertyChanged(nameof(HasLocalInputErrors)); + OnPropertyChanged(nameof(LocalInputErrorSummary)); + OnPropertyChanged(nameof(IsDirty)); + OnPropertyChanged(nameof(CanValidateOrSave)); + OnPropertyChanged(nameof(CanSwitchToRaw)); + NotifyCommandStates(); + } + + [RelayCommand] + private void AddRule() + { + Session.AddRule(); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void DuplicateRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.DuplicateRule(rule); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void ToggleRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.SetRuleEnabled(rule, !rule.Enabled); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void DeleteRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.DeleteRule(rule); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void MoveRuleUp(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + int index = Session.Draft.Rules.IndexOf(rule); + Session.MoveRule(rule, index - 1); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void MoveRuleDown(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + int index = Session.Draft.Rules.IndexOf(rule); + Session.MoveRule(rule, index + 1); + OnStructuredDraftChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task ValidateAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + string raw = Session.GetEffectiveRawJson(); + ReconcileDirtyAtBoundary(raw); + if (!TryGetDraftElement(raw, out JsonElement draft, out PolicyEditorSyntaxError? error)) + { + SyntaxError = error; + return; + } + + long generation = Interlocked.Increment(ref _validationGeneration); + PolicyEditorValidationOutcome outcome = + await ValidateCoreAsync(draft, cancellationToken); + if (!CanApply(cancellationToken) + || generation != Volatile.Read(ref _validationGeneration) + || !string.Equals(Session.GetEffectiveRawJson(), raw, StringComparison.Ordinal)) + return; + + if (outcome.Validation is not null) + Session.ApplyValidationResult( + raw, + outcome.Validation, + outcome.BoundedFindings, + outcome.OmittedFindingCount); + LastErrorCode = outcome.ErrorCode; + SyntaxError = null; + OnEditorStateChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task SaveAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + await SaveCoreAsync( + conflict: null, + PolicyConflictHandling.Reject, + cancellationToken); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task ConfirmOverwriteAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + PolicyEditorConflictSnapshot? conflict = Session.Conflict; + if (conflict is null || !Session.IsConflictCurrent(conflict)) + { + Session.ClearConflict(); + OnEditorStateChanged(); + return; + } + + var confirmation = new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.ConfirmOverwrite, + conflict.RetryDecision.Operation, + conflict.DraftId, + conflict.RetryDecision.Token, + conflict.RetryDecision.State, + conflict.RetryDecision.ActivePolicyId, + Findings); + bool confirmed; + IsBusy = true; + try + { + confirmed = await _confirmationPrompt.ConfirmAsync(confirmation, cancellationToken); + } + finally + { + IsBusy = false; + } + if (!confirmed || !CanApply(cancellationToken)) return; + + if (!CanApply(cancellationToken) || !Session.IsConflictCurrent(conflict)) + { + Session.ClearConflict(); + OnEditorStateChanged(); + return; + } + + await SaveCoreAsync( + conflict, + PolicyConflictHandling.ConfirmOverwrite, + cancellationToken); + } + + public async Task ConfirmDiscardAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + Task confirmation; + lock (_discardConfirmationLock) + { + if (_discardConfirmationTask is null) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + confirmation = completion.Task; + _discardConfirmationTask = confirmation; + _ = CompleteDiscardConfirmationAsync(completion); + } + else + { + confirmation = _discardConfirmationTask; + } + } + + return await confirmation.WaitAsync(cancellationToken); + } + + private async Task CompleteDiscardConfirmationAsync(TaskCompletionSource completion) + { + try + { + bool result = await ConfirmDiscardCoreAsync(_lifetimeCancellation.Token); + lock (_discardConfirmationLock) + { + completion.TrySetResult(result); + ClearDiscardConfirmation(completion.Task); + } + } + catch (OperationCanceledException ex) + { + lock (_discardConfirmationLock) + { + completion.TrySetCanceled(ex.CancellationToken); + ClearDiscardConfirmation(completion.Task); + } + } + catch (Exception ex) + { + lock (_discardConfirmationLock) + { + completion.TrySetException(ex); + ClearDiscardConfirmation(completion.Task); + } + } + } + + private void ClearDiscardConfirmation(Task confirmation) + { + if (ReferenceEquals(_discardConfirmationTask, confirmation)) + _discardConfirmationTask = null; + } + + private async Task ConfirmDiscardCoreAsync(CancellationToken cancellationToken) + { + if (IsBusy) + { + StatusMessage = CoreTools.Translate( + "Please wait for the current policy operation to finish before closing."); + return false; + } + + if (Session.IsDirty) + ReconcileDirtyAtBoundary(Session.GetEffectiveRawJson()); + if (!IsDirty) + return true; + + PolicyReplacementOperation operation = GetInitialOperation(); + return await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.DiscardChanges, + operation, + Session.Draft.Metadata.Id, + Session.OriginManagement.StoreToken, + Session.OriginManagement.State, + Session.OriginManagement.Policy?.Metadata.Id, + Findings), + cancellationToken); + } + + private async Task SaveCoreAsync( + PolicyEditorConflictSnapshot? conflict, + PolicyConflictHandling conflictHandling, + CancellationToken cancellationToken) + { + if (!CanStartRemoteOperation()) return; + + long saveGeneration = Interlocked.Increment(ref _saveGeneration); + IsBusy = true; + LastSaveSucceeded = false; + SavedWithNewerChanges = false; + SavedThenSuperseded = false; + LastErrorCode = null; + LastWriteFailureKind = PolicyWriteFailureKind.None; + try + { + string submitted = Session.GetEffectiveRawJson(); + long attemptGeneration = Session.MutationGeneration; + ReconcileDirtyAtBoundary(submitted); + + // Correction #14: reuse the exact current validation (same receipt/CanonicalDraft) when + // it still matches the unchanged draft/raw, instead of re-validating on every Save. A + // stale-token retry (ConfirmOverwrite) always revalidates to obtain a current receipt + // per correction #16, since the previously submitted receipt was already rejected by the + // write that produced the conflict. + PolicyEditorValidationState? validation = + conflictHandling != PolicyConflictHandling.ConfirmOverwrite && Session.IsValidationCurrent + ? Session.Validation + : null; + + if (validation is null) + { + if (!TryGetDraftElement( + submitted, + out JsonElement submittedElement, + out PolicyEditorSyntaxError? error)) + { + SyntaxError = error; + return; + } + + PolicyEditorValidationOutcome validationOutcome = + await _validationClient.ValidateAsync(submittedElement, cancellationToken); + if (!CanApply(cancellationToken) + || saveGeneration != Volatile.Read(ref _saveGeneration) + || Session.MutationGeneration != attemptGeneration) + return; + if (validationOutcome.Validation is null) + { + LastErrorCode = validationOutcome.ErrorCode; + return; + } + + Session.ApplyValidationResult( + submitted, + validationOutcome.Validation, + validationOutcome.BoundedFindings, + validationOutcome.OmittedFindingCount); + OnEditorStateChanged(); + validation = Session.Validation; + if (validation is null) + return; + } + + string canonicalRaw = PolicySerializer.Serialize(validation.CanonicalDraft); + + PolicyReplacementOperation operation; + string token; + PolicyManagementState state; + string? activePolicyId; + if (conflictHandling == PolicyConflictHandling.ConfirmOverwrite) + { + if (conflict is null + || !Session.IsConflictCurrent(conflict) + || !string.Equals( + canonicalRaw, + conflict.SubmittedCanonicalRawJson, + StringComparison.Ordinal) + || !string.Equals( + validation.CanonicalDraft.Metadata.Id, + conflict.DraftId, + StringComparison.Ordinal)) + { + Session.ClearConflict(); + return; + } + PolicyEditorRetryDecision decision = conflict.RetryDecision; + operation = decision.Operation; + token = decision.Token; + state = decision.State; + activePolicyId = decision.ActivePolicyId; + } + else + { + operation = ToReplacementOperation( + Session.ResolveOperationForDraftId(validation.CanonicalDraft.Metadata.Id)); + token = Session.OriginManagement.StoreToken; + state = Session.OriginManagement.State; + activePolicyId = Session.OriginManagement.Policy?.Metadata.Id; + } + + if (validation.HasWarnings && !Session.HasCurrentWarningAcknowledgement) + { + bool acknowledged = await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.Warnings, + operation, + validation.CanonicalDraft.Metadata.Id, + token, + state, + activePolicyId, + validation.Findings.All, + validation.WarningCount), + cancellationToken); + if (!CanApply(cancellationToken) + || saveGeneration != Volatile.Read(ref _saveGeneration)) + return; + if (Session.MutationGeneration != attemptGeneration) return; + if (!acknowledged) + return; + Session.AcknowledgeWarnings(); + } + + PolicyEditorConfirmationKind? operationConfirmation = + conflictHandling == PolicyConflictHandling.ConfirmOverwrite + ? null + : operation switch + { + PolicyReplacementOperation.ReplaceIdentity => + PolicyEditorConfirmationKind.ReplaceIdentity, + PolicyReplacementOperation.Create => + PolicyEditorConfirmationKind.Create, + PolicyReplacementOperation.Repair => + PolicyEditorConfirmationKind.Repair, + _ => null, + }; + if (operationConfirmation is { } kind + && !await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + kind, + operation, + validation.CanonicalDraft.Metadata.Id, + token, + state, + activePolicyId, + validation.Findings.All), + cancellationToken)) + return; + if (!CanApply(cancellationToken) + || saveGeneration != Volatile.Read(ref _saveGeneration)) + return; + if (Session.MutationGeneration != attemptGeneration) return; + + using JsonDocument canonicalDocument = JsonDocument.Parse(canonicalRaw); + var request = new PolicyEditorWriteRequest( + operation, + conflictHandling, + token, + canonicalDocument.RootElement.Clone(), + validation.Receipt, + validation.HasWarnings && Session.HasCurrentWarningAcknowledgement); + PolicyWriteOutcome write = + await _writeClient.WriteAsync(request, cancellationToken); + if (!CanApplyDispatchedWrite(saveGeneration)) return; + PublishWriteCompletion( + saveGeneration, + write, + Session.MutationGeneration != attemptGeneration); + + if (write.FailureKind == PolicyWriteFailureKind.WriteResultUnknown) + { + LastWriteFailureKind = write.FailureKind; + LastErrorCode = write.Error?.Code; + RequiresManagementRefresh = true; + OnEditorStateChanged(); + } + + if (Session.MutationGeneration != attemptGeneration) + { + if (write.Response is not null) + { + Session.MarkSavedPreservingCurrentDraft(write.Response, attemptGeneration); + SavedWithNewerChanges = true; + LastSaveSucceeded = true; + ScheduleCurrentModeDirtyAnalysis(); + OnEditorStateChanged(); + } + + return; + } + + if (conflictHandling == PolicyConflictHandling.ConfirmOverwrite + && (conflict is null || !Session.IsConflictCurrent(conflict))) + { + Session.ClearConflict(); + return; + } + + if (write.Response is not null) + { + Session.MarkSaved(write.Response); + SavedWithNewerChanges = false; + SavedThenSuperseded = write.SavedThenSuperseded; + LastSaveSucceeded = true; + StatusMessage = ""; + OnEditorStateChanged(); + return; + } + + LastWriteFailureKind = write.FailureKind; + LastErrorCode = write.Error?.Code; + RequiresManagementRefresh = + write.FailureKind == PolicyWriteFailureKind.WriteResultUnknown; + if (write.ConflictDecision is { } conflictDecision) + { + Session.CaptureConflict( + conflictDecision, + validation.CanonicalDraft, + validation.Receipt, + validation.CanonicalDraft.Metadata.Id); + } + else if (write.Error is + { + Code: ErrorCode.StalePolicyStoreToken, + Management: not null, + }) + { + Session.CaptureConflict( + write.Error.Management, + validation.CanonicalDraft, + validation.Receipt, + validation.CanonicalDraft.Metadata.Id); + } + OnEditorStateChanged(); + } + finally + { + if (Volatile.Read(ref _isDisposed) != 0 + || saveGeneration == Volatile.Read(ref _saveGeneration)) + { + StatusMessage = ""; + IsBusy = false; + } + } + } + + private async Task ValidateCoreAsync( + JsonElement draft, + CancellationToken cancellationToken) + { + IsBusy = true; + try + { + return await _validationClient.ValidateAsync(draft, cancellationToken); + } + finally + { + StatusMessage = ""; + IsBusy = false; + } + } + + private bool CanStartRemoteOperation() => + Volatile.Read(ref _isDisposed) == 0 + && !IsBusy + && !HasLocalInputErrors + && !IsRawSyntaxPending + && !RequiresManagementRefresh + && SyntaxError is null; + + private bool CanStartStructuredOperation() => + Volatile.Read(ref _isDisposed) == 0 + && !IsBusy + && !HasLocalInputErrors; + + private bool CanApply(CancellationToken cancellationToken) => + Volatile.Read(ref _isDisposed) == 0 && !cancellationToken.IsCancellationRequested; + + private bool CanApplyDispatchedWrite(long saveGeneration) => + Volatile.Read(ref _isDisposed) == 0 + && saveGeneration == Volatile.Read(ref _saveGeneration); + + private void PublishWriteCompletion( + long generation, + PolicyWriteOutcome write, + bool hasNewerChanges) + { + PolicyEditorWriteCompletionKind kind; + if (write.Response is not null) + { + kind = hasNewerChanges + ? PolicyEditorWriteCompletionKind.SavedWithNewerChanges + : write.SavedThenSuperseded + ? PolicyEditorWriteCompletionKind.SavedThenSuperseded + : PolicyEditorWriteCompletionKind.Saved; + } + else if (write.FailureKind != PolicyWriteFailureKind.WriteResultUnknown + && (write.ConflictDecision is not null + || write.Error is + { + Code: ErrorCode.StalePolicyStoreToken, + Management: not null, + })) + { + kind = PolicyEditorWriteCompletionKind.Conflict; + } + else + { + kind = PolicyEditorWriteCompletionKind.Failed; + } + + LastWriteCompletion = new( + generation, + kind, + write.FailureKind, + write.Error?.Code); + OnPropertyChanged(nameof(LastWriteCompletion)); + } + + private CancellationTokenSource CreateLinkedCancellation(CancellationToken cancellationToken) => + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetimeCancellation.Token); + + partial void OnIsBusyChanged(bool value) => NotifyCommandStates(); + + partial void OnSyntaxErrorChanged(PolicyEditorSyntaxError? value) + { + OnPropertyChanged(nameof(SyntaxErrorTitle)); + OnPropertyChanged(nameof(SyntaxErrorMessage)); + NotifyCommandStates(); + } + + partial void OnRequiresManagementRefreshChanged(bool value) => NotifyCommandStates(); + + private void NotifyCommandStates() + { + OnPropertyChanged(nameof(CanValidateOrSave)); + OnPropertyChanged(nameof(CanSwitchToRaw)); + SwitchToRawCommand.NotifyCanExecuteChanged(); + SwitchToStructuredCommand.NotifyCanExecuteChanged(); + ValidateCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); + ConfirmOverwriteCommand.NotifyCanExecuteChanged(); + } + + private bool TryGetDraftElement( + string raw, + out JsonElement element, + out PolicyEditorSyntaxError? error) + { + element = default; + if (Session.Mode == PolicyEditorMode.Raw + && string.Equals(raw, Session.RawBuffer, StringComparison.Ordinal) + && Session.TryGetAnalyzedRawElement(out element)) + { + error = null; + return true; + } + + return PolicyEditorRawSyntax.TryParseStrictWithElement( + raw, + out _, + out element, + out error); + } + + private PolicyReplacementOperation GetInitialOperation() => + ToReplacementOperation(Session.Operation); + + private static PolicyReplacementOperation ToReplacementOperation( + PolicyEditorOperationKind operation) => operation switch + { + PolicyEditorOperationKind.Update => PolicyReplacementOperation.Update, + PolicyEditorOperationKind.ReplaceIdentity => PolicyReplacementOperation.ReplaceIdentity, + PolicyEditorOperationKind.Create => PolicyReplacementOperation.Create, + PolicyEditorOperationKind.Repair => PolicyReplacementOperation.Repair, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null), + }; + + private void OnEditorStateChanged() + { + OnPropertyChanged(nameof(Draft)); + OnPropertyChanged(nameof(Rules)); + OnPropertyChanged(nameof(Operation)); + OnPropertyChanged(nameof(RawBuffer)); + OnPropertyChanged(nameof(IsStructuredMode)); + OnPropertyChanged(nameof(IsRawMode)); + OnPropertyChanged(nameof(IsDirty)); + OnPropertyChanged(nameof(IsIdentityLocked)); + OnPropertyChanged(nameof(HasFindings)); + OnPropertyChanged(nameof(HasConflict)); + OnPropertyChanged(nameof(Findings)); + OnPropertyChanged(nameof(IsRawSyntaxPending)); + NotifyCommandStates(); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) return; + Interlocked.Increment(ref _validationGeneration); + Interlocked.Increment(ref _saveGeneration); + CancelRawSyntaxAnalysis(); + CancelStructuredDirtyAnalysis(); + _lifetimeCancellation.Cancel(); + _lifetimeCancellation.Dispose(); + NotifyCommandStates(); + } + + internal Task WaitForRawSyntaxAnalysisAsync() => _rawSyntaxAnalysis; + + internal Task WaitForStructuredDirtyAnalysisAsync() => _structuredDirtyAnalysis; + + private void OnStructuredDraftChanged() + { + ScheduleStructuredDirtyAnalysis(); + OnEditorStateChanged(); + } + + private void ReconcileDirtyAtBoundary(string effectiveRawJson) + { + PolicyEditorDirtyComparisonSnapshot snapshot = Session.CaptureDirtyComparison(); + ApplyDirtyComparison( + snapshot, + !string.Equals( + effectiveRawJson, + snapshot.BaselineRawJson, + StringComparison.Ordinal)); + } + + private void ScheduleStructuredDirtyAnalysis() + { + if (Session.Mode != PolicyEditorMode.Structured) + return; + + PolicyEditorDirtyComparisonSnapshot snapshot = Session.CaptureDirtyComparison(); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + CancellationTokenSource? previous = + Interlocked.Exchange(ref _structuredDirtyCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + _structuredDirtyAnalysis = AnalyzeStructuredDirtyAsync(snapshot, cancellation); + } + + private void ScheduleCurrentModeDirtyAnalysis() + { + if (Session.Mode == PolicyEditorMode.Raw) + ScheduleRawSyntaxAnalysis(Session.RawBuffer); + else + ScheduleStructuredDirtyAnalysis(); + } + + private bool ApplyDirtyComparison( + PolicyEditorDirtyComparisonSnapshot snapshot, + bool isDirty) + { + if (!Session.TryApplyDirtyComparison(snapshot, isDirty)) + return false; + + if (!isDirty && !HasLocalInputErrors) + SavedWithNewerChanges = false; + return true; + } + + private async Task AnalyzeStructuredDirtyAsync( + PolicyEditorDirtyComparisonSnapshot snapshot, + CancellationTokenSource cancellation) + { + try + { + await Task.Delay(_structuredDirtyDebounce, cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0) + { + return; + } + + PolicyEditorDraftDocument draftSnapshot = Session.Draft.Clone(); + bool isDirty = await Task.Run( + () => !string.Equals( + _structuredDraftSerializer(draftSnapshot), + snapshot.BaselineRawJson, + StringComparison.Ordinal), + cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0) + { + return; + } + + if (!ApplyDirtyComparison(snapshot, isDirty)) + { + return; + } + + // This continuation intentionally resumes on the UI context captured by the edit. + OnPropertyChanged(nameof(IsDirty)); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + catch (InvalidOperationException) when ( + cancellation.IsCancellationRequested + || snapshot.MutationGeneration != Session.MutationGeneration) + { + } + finally + { + if (ReferenceEquals( + Interlocked.CompareExchange( + ref _structuredDirtyCancellation, + null, + cancellation), + cancellation)) + { + cancellation.Dispose(); + } + } + + } + + private void CancelStructuredDirtyAnalysis() + { + CancellationTokenSource? cancellation = + Interlocked.Exchange(ref _structuredDirtyCancellation, null); + cancellation?.Cancel(); + cancellation?.Dispose(); + _structuredDirtyAnalysis = Task.CompletedTask; + } + + private void ScheduleRawSyntaxAnalysis(string raw) + { + long mutationGeneration = Session.MutationGeneration; + PolicyEditorDirtyComparisonSnapshot dirtySnapshot = Session.CaptureDirtyComparison(); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + CancellationTokenSource? previous = + Interlocked.Exchange(ref _rawSyntaxCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + _rawSyntaxAnalysis = AnalyzeRawSyntaxAsync( + raw, + mutationGeneration, + dirtySnapshot, + cancellation); + } + + private async Task AnalyzeRawSyntaxAsync( + string raw, + long mutationGeneration, + PolicyEditorDirtyComparisonSnapshot dirtySnapshot, + CancellationTokenSource cancellation) + { + try + { + await Task.Delay(_rawSyntaxDebounce, cancellation.Token); + ( + PolicyEditorSyntaxError? Error, + string? CanonicalRaw, + string? DraftId, + JsonElement? RawElement, + bool IsDirty) result = + await Task.Run<( + PolicyEditorSyntaxError? Error, + string? CanonicalRaw, + string? DraftId, + JsonElement? RawElement, + bool IsDirty)>( + () => + { + bool parsed = PolicyEditorRawSyntax.TryParseStrictWithElement( + raw, + out PolicyEditorDraftDocument? draft, + out JsonElement element, + out PolicyEditorSyntaxError? error); + return ( + error, + parsed && draft is not null + ? PolicyEditorRawSyntax.ToCanonicalRaw(draft) + : null, + parsed ? draft?.Metadata.Id : null, + parsed ? (JsonElement?)element : null, + !string.Equals( + raw, + dirtySnapshot.BaselineRawJson, + StringComparison.Ordinal)); + }, + cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0 + || !Session.CompleteRawAnalysis( + raw, + mutationGeneration, + result.CanonicalRaw, + result.DraftId, + result.RawElement)) + { + return; + } + + ApplyDirtyComparison(dirtySnapshot, result.IsDirty); + SyntaxError = result.Error; + OnEditorStateChanged(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + if (ReferenceEquals( + Interlocked.CompareExchange( + ref _rawSyntaxCancellation, + null, + cancellation), + cancellation)) + { + cancellation.Dispose(); + } + } + } + + private void CancelRawSyntaxAnalysis() + { + CancellationTokenSource? cancellation = + Interlocked.Exchange(ref _rawSyntaxCancellation, null); + cancellation?.Cancel(); + cancellation?.Dispose(); + } +} + +internal enum PolicyEditorWriteCompletionKind +{ + Saved, + SavedWithNewerChanges, + SavedThenSuperseded, + Conflict, + Failed, +} + +internal sealed record PolicyEditorWriteCompletion( + long Generation, + PolicyEditorWriteCompletionKind Kind, + PolicyWriteFailureKind FailureKind, + ErrorCode? ErrorCode); diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs new file mode 100644 index 0000000000..2d065e2e44 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs @@ -0,0 +1,868 @@ +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A single checkbox-style option for a multi-select enum match field (e.g. Operations, Managers, +/// Scopes, Architectures, Elevation). Deliberately non-generic (one concrete type serves every enum +/// list) so a single compiled AXAML DataTemplate can render all of them. +/// +public sealed partial class PolicyEditorEnumOption : ObservableObject +{ + private readonly Action _onToggled; + + public string Display { get; } + + [ObservableProperty] + private bool _isSelected; + + public PolicyEditorEnumOption(string display, bool isSelected, Action onToggled) + { + Display = display; + _isSelected = isSelected; + _onToggled = onToggled; + } + + partial void OnIsSelectedChanged(bool value) => _onToggled(value); +} + +/// Builds lists for every value of a match enum. +internal static class PolicyEditorEnumOptionFactory +{ + public static List Build(List backing, Action markDirty) + where TEnum : struct, Enum + { + return Enum.GetValues() + .Select(value => new PolicyEditorEnumOption( + CoreTools.Translate(value.ToString()), + backing.Contains(value), + selected => + { + if (selected) + { + if (!backing.Contains(value)) backing.Add(value); + } + else + { + backing.Remove(value); + } + + markDirty(); + })) + .ToList(); + } +} + +/// +/// Shared, index-based single-select enum lists (Decision, tri-state). Mirrors the codebase's +/// established "translated display strings + SelectedIndex" ComboBox pattern (see +/// BaseLogPage.axaml) instead of a ComboBox.ItemTemplate, so no compiled-binding +/// x:DataType is needed for a raw enum value. +/// +internal static class PolicyEditorEnumDisplay +{ + public static readonly Decision[] Decisions = [Decision.Allow, Decision.Deny]; + + public static readonly IReadOnlyList DecisionDisplayItems = + Decisions.Select(value => CoreTools.Translate(value.ToString())).ToList(); + + public static readonly TriState[] TriStates = [TriState.Omitted, TriState.False, TriState.True]; + + public static readonly IReadOnlyList TriStateDisplayItems = + [ + CoreTools.Translate("Any"), + CoreTools.Translate("No"), + CoreTools.Translate("Yes"), + ]; + + /// Not set / No / Yes, for the nullable-boolean audit-mode field. + public static readonly IReadOnlyList NullableBooleanDisplayItems = + [ + CoreTools.Translate("Not set"), + CoreTools.Translate("No"), + CoreTools.Translate("Yes"), + ]; + + public static int IndexOfDecision(Decision value) => Array.IndexOf(Decisions, value); + + public static int IndexOfTriState(TriState value) => Array.IndexOf(TriStates, value); + + public static int IndexOfNullableBoolean(bool? value) => value switch + { + null => 0, + false => 1, + true => 2, + }; + + public static bool? NullableBooleanFromIndex(int index) => index switch + { + 1 => false, + 2 => true, + _ => null, + }; +} + +/// +/// UI-facing wrapper over the document-level and +/// , exposing convenience index/text properties the +/// structured editor's AXAML can bind directly (compiled bindings require a concrete get/set surface; +/// the draft POCOs are plain mutable objects with no change notification of their own). Every setter +/// routes through so validation, +/// findings and dirty state stay in sync without rebuilding this wrapper on every keystroke. +/// +public sealed class PolicyEditorDocumentUi : ObservableObject +{ + private readonly PolicyEditorSessionViewModel _sessionViewModel; + private readonly object _validFromErrorKey = new(); + private readonly object _validUntilErrorKey = new(); + private string _validFromText; + private string _validUntilText; + private string? _validFromError; + private string? _validUntilError; + + public PolicyEditorDocumentUi(PolicyEditorSessionViewModel sessionViewModel) + { + _sessionViewModel = sessionViewModel; + _validFromText = Format(Draft.Metadata.ValidFrom); + _validUntilText = Format(Draft.Metadata.ValidUntil); + } + + private PolicyEditorDraftDocument Draft => _sessionViewModel.Draft; + + public bool IsIdentityLocked => _sessionViewModel.IsIdentityLocked; + + public void NotifyIdentityLockChanged() => + OnPropertyChanged(nameof(IsIdentityLocked)); + + public string PolicyFormatVersion => Draft.PolicyFormatVersion.Value; + public IReadOnlyList PolicyFormatVersionFindings => + FindingsFor("/PolicyFormatVersion"); + public bool HasPolicyFormatVersionErrors => HasErrors(PolicyFormatVersionFindings); + + public string Id + { + get => Draft.Metadata.Id; + set { Draft.Metadata.Id = value ?? ""; MarkDirty(); } + } + public IReadOnlyList IdFindings => FindingsFor("/Metadata/Id"); + public bool HasIdErrors => HasErrors(IdFindings); + + public string Publisher + { + get => Draft.Metadata.Publisher; + set { Draft.Metadata.Publisher = value ?? ""; MarkDirty(); } + } + public IReadOnlyList PublisherFindings => FindingsFor("/Metadata/Publisher"); + public bool HasPublisherErrors => HasErrors(PublisherFindings); + + public string? Description + { + get => Draft.Metadata.Description; + set + { + if (!HasDescription && string.IsNullOrEmpty(value)) return; + if (string.Equals(Draft.Metadata.Description, value, StringComparison.Ordinal)) return; + bool hadDescription = HasDescription; + Draft.Metadata.Description = value; + if (hadDescription != HasDescription) + OnPropertyChanged(nameof(HasDescription)); + MarkDirty(); + } + } + public IReadOnlyList DescriptionFindings => FindingsFor("/Metadata/Description"); + public bool HasDescriptionErrors => HasErrors(DescriptionFindings); + + public bool HasDescription + { + get => Draft.Metadata.Description is not null; + set + { + if (value == HasDescription) return; + Draft.Metadata.Description = value ? "" : null; + OnPropertyChanged(); + OnPropertyChanged(nameof(Description)); + MarkDirty(); + } + } + + public string? SupportUrl + { + get => Draft.Metadata.SupportUrl; + set + { + string? normalized = string.IsNullOrEmpty(value) ? null : value; + if (string.Equals(Draft.Metadata.SupportUrl, normalized, StringComparison.Ordinal)) + return; + Draft.Metadata.SupportUrl = normalized; + MarkDirty(); + } + } + public IReadOnlyList SupportUrlFindings => FindingsFor("/Metadata/SupportUrl"); + public bool HasSupportUrlErrors => HasErrors(SupportUrlFindings); + + /// Round-trip ISO-8601 text. Invalid input is retained and blocks validation/save. + public string ValidFromText + { + get => _validFromText; + set + { + value ??= ""; + if (string.Equals(_validFromText, value, StringComparison.Ordinal)) return; + _validFromText = value; + OnPropertyChanged(); + if (TryParse(value, out DateTimeOffset? parsed)) + { + Draft.Metadata.ValidFrom = parsed; + SetValidFromError(null); + MarkDirty(); + } + else + { + SetValidFromError(CoreTools.Translate("Enter a valid ISO 8601 date and time.")); + _sessionViewModel.NotifyLocalInputChanged(); + } + } + } + + public string ValidUntilText + { + get => _validUntilText; + set + { + value ??= ""; + if (string.Equals(_validUntilText, value, StringComparison.Ordinal)) return; + _validUntilText = value; + OnPropertyChanged(); + if (TryParse(value, out DateTimeOffset? parsed)) + { + Draft.Metadata.ValidUntil = parsed; + SetValidUntilError(null); + MarkDirty(); + } + else + { + SetValidUntilError(CoreTools.Translate("Enter a valid ISO 8601 date and time.")); + _sessionViewModel.NotifyLocalInputChanged(); + } + } + } + + public string? ValidFromError => _validFromError; + public string? ValidUntilError => _validUntilError; + public IReadOnlyList ValidFromFindings => + [.. FindingsFor("/Metadata/ValidFrom"), .. FindingsForExact("/Metadata")]; + public IReadOnlyList ValidUntilFindings => + [.. FindingsFor("/Metadata/ValidUntil"), .. FindingsForExact("/Metadata")]; + public bool HasValidFromErrors => HasErrors(ValidFromFindings); + public bool HasValidUntilErrors => HasErrors(ValidUntilFindings); + + public int DecisionIndex + { + get => PolicyEditorEnumDisplay.IndexOfDecision(Draft.Enforcement.DefaultDecision); + set + { + if (value >= 0 && value < PolicyEditorEnumDisplay.Decisions.Length) + { + Draft.Enforcement.DefaultDecision = PolicyEditorEnumDisplay.Decisions[value]; + MarkDirty(); + } + } + } + public IReadOnlyList DefaultDecisionFindings => + FindingsFor("/Enforcement/DefaultDecision"); + public bool HasDefaultDecisionErrors => HasErrors(DefaultDecisionFindings); + + public string RulePrecedenceDisplay => CoreTools.Translate(Draft.Enforcement.RulePrecedence.ToString()); + public IReadOnlyList RulePrecedenceFindings => + FindingsFor("/Enforcement/RulePrecedence"); + public bool HasRulePrecedenceErrors => HasErrors(RulePrecedenceFindings); + + public int AuditModeIndex + { + get => PolicyEditorEnumDisplay.IndexOfNullableBoolean(Draft.Enforcement.AuditMode); + set + { + Draft.Enforcement.AuditMode = PolicyEditorEnumDisplay.NullableBooleanFromIndex(value); + MarkDirty(); + } + } + public IReadOnlyList AuditModeFindings => + FindingsFor("/Enforcement/AuditMode"); + public bool HasAuditModeErrors => HasErrors(AuditModeFindings); + + private void MarkDirty() => _sessionViewModel.NotifyDraftChangedCommand.Execute(null); + + public void RefreshFromDraft() + { + _validFromText = Format(Draft.Metadata.ValidFrom); + _validUntilText = Format(Draft.Metadata.ValidUntil); + SetValidFromError(null); + SetValidUntilError(null); + OnPropertyChanged(nameof(PolicyFormatVersion)); + OnPropertyChanged(nameof(Id)); + OnPropertyChanged(nameof(Publisher)); + OnPropertyChanged(nameof(Description)); + OnPropertyChanged(nameof(HasDescription)); + OnPropertyChanged(nameof(SupportUrl)); + OnPropertyChanged(nameof(ValidFromText)); + OnPropertyChanged(nameof(ValidUntilText)); + OnPropertyChanged(nameof(ValidFromError)); + OnPropertyChanged(nameof(ValidUntilError)); + OnPropertyChanged(nameof(DecisionIndex)); + OnPropertyChanged(nameof(AuditModeIndex)); + OnPropertyChanged(nameof(RulePrecedenceDisplay)); + OnPropertyChanged(nameof(IsIdentityLocked)); + RefreshFindings(); + } + + public void RefreshFindings() + { + foreach (string property in new[] + { + nameof(PolicyFormatVersionFindings), nameof(HasPolicyFormatVersionErrors), + nameof(IdFindings), nameof(HasIdErrors), + nameof(PublisherFindings), nameof(HasPublisherErrors), + nameof(DescriptionFindings), nameof(HasDescriptionErrors), + nameof(SupportUrlFindings), nameof(HasSupportUrlErrors), + nameof(ValidFromFindings), nameof(HasValidFromErrors), + nameof(ValidUntilFindings), nameof(HasValidUntilErrors), + nameof(DefaultDecisionFindings), nameof(HasDefaultDecisionErrors), + nameof(RulePrecedenceFindings), nameof(HasRulePrecedenceErrors), + nameof(AuditModeFindings), nameof(HasAuditModeErrors), + }) + { + OnPropertyChanged(property); + } + } + + private IReadOnlyList FindingsFor(params string[] pointers) => + _sessionViewModel.Session.Findings.All + .Where(finding => pointers.Any(finding.TargetsPointer)) + .ToArray(); + + private IReadOnlyList FindingsForExact(string pointer) => + _sessionViewModel.Session.Findings.All + .Where(finding => finding.Pointer.Equals(pointer, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + private static bool HasErrors(IEnumerable findings) => + findings.Any(finding => finding.IsError); + + private void SetValidFromError(string? error) + { + if (string.Equals(_validFromError, error, StringComparison.Ordinal)) return; + _validFromError = error; + _sessionViewModel.SetLocalInputError(_validFromErrorKey, error); + OnPropertyChanged(nameof(ValidFromError)); + } + + private void SetValidUntilError(string? error) + { + if (string.Equals(_validUntilError, error, StringComparison.Ordinal)) return; + _validUntilError = error; + _sessionViewModel.SetLocalInputError(_validUntilErrorKey, error); + OnPropertyChanged(nameof(ValidUntilError)); + } + + private static string Format(DateTimeOffset? value) => + value?.ToString("O", CultureInfo.InvariantCulture) ?? ""; + + private static bool TryParse(string? text, out DateTimeOffset? parsed) + { + if (string.IsNullOrWhiteSpace(text)) + { + parsed = null; + return true; + } + + string normalized = text.EndsWith('Z') + ? text[..^1] + "+00:00" + : text; + string[] formats = + [ + "yyyy-MM-dd'T'HH:mm:sszzz", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz", + ]; + if (DateTimeOffset.TryParseExact( + normalized, + formats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTimeOffset value)) + { + parsed = value; + return true; + } + + parsed = null; + return false; + } +} + +/// +/// UI-facing wrapper over a single : every field of +/// and , projected as +/// bindable properties (string-joined lists, index-based enum pickers, on-demand nullable +/// sub-object creation for VersionRange/Constraints). See +/// for why every setter routes through NotifyDraftChangedCommand instead of raising its own +/// change notification. +/// +public sealed class PolicyEditorRuleUi : ObservableObject, IDisposable +{ + private readonly PolicyEditorSessionViewModel _sessionViewModel; + private readonly int _ruleIndex; + private readonly object _priorityErrorKey = new(); + private string _priorityText; + private string? _priorityError; + + public PolicyEditorDraftRule Rule { get; } + + public PolicyEditorRuleUi( + PolicyEditorDraftRule rule, + int ruleIndex, + PolicyEditorSessionViewModel sessionViewModel) + { + Rule = rule; + _ruleIndex = ruleIndex; + _sessionViewModel = sessionViewModel; + _priorityText = Rule.Priority.ToString(CultureInfo.InvariantCulture); + + OperationOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Operations, MarkDirty); + ManagerOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Managers, MarkDirty); + ScopeOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Scopes, MarkDirty); + ArchitectureOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Architectures, MarkDirty); + ElevationOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Elevation, MarkDirty); + } + + public PolicyEditorRuleUi( + PolicyEditorDraftRule rule, + PolicyEditorSessionViewModel sessionViewModel) + : this(rule, sessionViewModel.Draft.Rules.IndexOf(rule), sessionViewModel) + { + } + + public string Id + { + get => Rule.Id; + set + { + value ??= ""; + if (string.Equals(Rule.Id, value, StringComparison.Ordinal)) return; + Rule.Id = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(AutomationName)); + MarkDirty(); + } + } + public IReadOnlyList IdFindings => FindingsFor("/Id"); + public bool HasIdErrors => HasErrors(IdFindings); + + public bool Enabled + { + get => Rule.Enabled; + set { Rule.Enabled = value; MarkDirty(); } + } + + public string PriorityText + { + get => _priorityText; + set + { + value ??= ""; + if (string.Equals(_priorityText, value, StringComparison.Ordinal)) return; + _priorityText = value; + OnPropertyChanged(); + if (uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed) + && parsed <= int.MaxValue) + { + Rule.Priority = parsed; + SetPriorityError(null); + MarkDirty(); + } + else + { + SetPriorityError(CoreTools.Translate("Enter a whole number from 0 through 2147483647.")); + _sessionViewModel.NotifyLocalInputChanged(); + } + } + } + public IReadOnlyList PriorityFindings => FindingsFor("/Priority"); + public bool HasPriorityErrors => HasErrors(PriorityFindings); + + public string? PriorityError => _priorityError; + + public int DecisionIndex + { + get => PolicyEditorEnumDisplay.IndexOfDecision(Rule.Decision); + set + { + if (value >= 0 && value < PolicyEditorEnumDisplay.Decisions.Length) + { + Rule.Decision = PolicyEditorEnumDisplay.Decisions[value]; + MarkDirty(); + } + } + } + public IReadOnlyList DecisionFindings => FindingsFor("/Decision"); + public bool HasDecisionErrors => HasErrors(DecisionFindings); + + public string? Reason + { + get => Rule.Reason; + set + { + if (!HasReason && string.IsNullOrEmpty(value)) return; + if (string.Equals(Rule.Reason, value, StringComparison.Ordinal)) return; + bool hadReason = HasReason; + Rule.Reason = value; + if (hadReason != HasReason) + OnPropertyChanged(nameof(HasReason)); + MarkDirty(); + } + } + public IReadOnlyList ReasonFindings => FindingsFor("/Reason"); + public bool HasReasonErrors => HasErrors(ReasonFindings); + + public bool HasReason + { + get => Rule.Reason is not null; + set + { + if (value == HasReason) return; + Rule.Reason = value ? "" : null; + OnPropertyChanged(); + OnPropertyChanged(nameof(Reason)); + MarkDirty(); + } + } + + public string AutomationName => CoreTools.Translate( + "Rule: {0}", + string.IsNullOrWhiteSpace(Rule.Id) ? CoreTools.Translate("(untitled)") : Rule.Id); + + public IReadOnlyList OperationOptions { get; } + public IReadOnlyList ManagerOptions { get; } + public IReadOnlyList ScopeOptions { get; } + public IReadOnlyList ArchitectureOptions { get; } + public IReadOnlyList ElevationOptions { get; } + + public string Sources + { + get => Join(Rule.Match.Sources); + set => SetListField(Rule.Match.Sources, value); + } + + public string PackageIdentifiers + { + get => Join(Rule.Match.PackageIdentifiers); + set => SetListField(Rule.Match.PackageIdentifiers, value); + } + + public string PackageNames + { + get => Join(Rule.Match.PackageNames); + set => SetListField(Rule.Match.PackageNames, value); + } + public IReadOnlyList PackageNamesFindings => + FindingsFor("/Match/PackageNames"); + public bool HasPackageNamesErrors => HasErrors(PackageNamesFindings); + + public string Versions + { + get => Join(Rule.Match.Versions); + set => SetListField(Rule.Match.Versions, value); + } + public IReadOnlyList VersionsFindings => FindingsFor("/Match/Versions"); + public bool HasVersionsErrors => HasErrors(VersionsFindings); + + public bool HasVersionRange + { + get => Rule.Match.VersionRange is not null; + set + { + if (value == (Rule.Match.VersionRange is not null)) return; + Rule.Match.VersionRange = value ? new PolicyEditorDraftVersionRange() : null; + MarkDirty(); + OnPropertyChanged(); + NotifyVersionRangePropertiesChanged(); + } + } + + public string? MinVersion + { + get => Rule.Match.VersionRange?.MinVersion; + set { EnsureVersionRange().MinVersion = string.IsNullOrEmpty(value) ? null : value; MarkDirty(); } + } + public IReadOnlyList MinVersionFindings => + [.. FindingsFor("/Match/VersionRange/MinVersion"), .. FindingsEndingAt("/Match/VersionRange")]; + public bool HasMinVersionErrors => HasErrors(MinVersionFindings); + + public string? MaxVersion + { + get => Rule.Match.VersionRange?.MaxVersion; + set { EnsureVersionRange().MaxVersion = string.IsNullOrEmpty(value) ? null : value; MarkDirty(); } + } + public IReadOnlyList MaxVersionFindings => + [.. FindingsFor("/Match/VersionRange/MaxVersion"), .. FindingsEndingAt("/Match/VersionRange")]; + public bool HasMaxVersionErrors => HasErrors(MaxVersionFindings); + + public bool IncludePrerelease + { + get => Rule.Match.VersionRange?.IncludePrerelease ?? false; + set { EnsureVersionRange().IncludePrerelease = value; MarkDirty(); } + } + + public int InteractiveIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.Interactive); + set => SetTriState(v => Rule.Match.Interactive = v, value); + } + + public int SkipHashCheckIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.SkipHashCheck); + set => SetTriState(v => Rule.Match.SkipHashCheck = v, value); + } + + public int PreReleaseIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.PreRelease); + set => SetTriState(v => Rule.Match.PreRelease = v, value); + } + + public int HasCustomParametersIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasCustomParameters); + set => SetTriState(v => Rule.Match.HasCustomParameters = v, value); + } + + public int HasCustomInstallLocationIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasCustomInstallLocation); + set => SetTriState(v => Rule.Match.HasCustomInstallLocation = v, value); + } + + public int HasPrePostCommandsIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasPrePostCommands); + set => SetTriState(v => Rule.Match.HasPrePostCommands = v, value); + } + + public int HasKillBeforeOperationIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasKillBeforeOperation); + set => SetTriState(v => Rule.Match.HasKillBeforeOperation = v, value); + } + + public int HasUninstallPreviousIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasUninstallPrevious); + set => SetTriState(v => Rule.Match.HasUninstallPrevious = v, value); + } + + public bool HasConstraints + { + get => Rule.Constraints is not null; + set + { + if (value == (Rule.Constraints is not null)) return; + Rule.Constraints = value ? new PolicyEditorDraftConstraints() : null; + MarkDirty(); + OnPropertyChanged(); + NotifyConstraintPropertiesChanged(); + } + } + + public bool AllowInteractive + { + get => Rule.Constraints?.AllowInteractive ?? false; + set { EnsureConstraints().AllowInteractive = value; MarkDirty(); } + } + + public bool AllowSkipHashCheck + { + get => Rule.Constraints?.AllowSkipHashCheck ?? false; + set { EnsureConstraints().AllowSkipHashCheck = value; MarkDirty(); } + } + + public bool AllowPreRelease + { + get => Rule.Constraints?.AllowPreRelease ?? false; + set { EnsureConstraints().AllowPreRelease = value; MarkDirty(); } + } + + public bool AllowCustomInstallLocation + { + get => Rule.Constraints?.AllowCustomInstallLocation ?? false; + set { EnsureConstraints().AllowCustomInstallLocation = value; MarkDirty(); } + } + + public string AllowedInstallLocationPatterns + { + get => Join(Rule.Constraints?.AllowedInstallLocationPatterns); + set => SetListField(EnsureConstraints().AllowedInstallLocationPatterns, value); + } + + public bool AllowCustomParameters + { + get => Rule.Constraints?.AllowCustomParameters ?? false; + set { EnsureConstraints().AllowCustomParameters = value; MarkDirty(); } + } + + public string AllowedCustomParameters + { + get => Join(Rule.Constraints?.AllowedCustomParameters); + set => SetListField(EnsureConstraints().AllowedCustomParameters, value); + } + + public string AllowedCustomParameterPatterns + { + get => Join(Rule.Constraints?.AllowedCustomParameterPatterns); + set => SetListField(EnsureConstraints().AllowedCustomParameterPatterns, value); + } + + public string DeniedCustomParameters + { + get => Join(Rule.Constraints?.DeniedCustomParameters); + set => SetListField(EnsureConstraints().DeniedCustomParameters, value); + } + + public bool AllowPrePostCommands + { + get => Rule.Constraints?.AllowPrePostCommands ?? false; + set { EnsureConstraints().AllowPrePostCommands = value; MarkDirty(); } + } + + public bool AllowKillBeforeOperation + { + get => Rule.Constraints?.AllowKillBeforeOperation ?? false; + set { EnsureConstraints().AllowKillBeforeOperation = value; MarkDirty(); } + } + + public bool AllowUninstallPrevious + { + get => Rule.Constraints?.AllowUninstallPrevious ?? false; + set { EnsureConstraints().AllowUninstallPrevious = value; MarkDirty(); } + } + + public bool AllowUpgrade + { + get => Rule.Constraints?.AllowUpgrade ?? false; + set { EnsureConstraints().AllowUpgrade = value; MarkDirty(); } + } + + /// Findings attributed to this rule's identifier or document index. + public IReadOnlyList Findings => + _sessionViewModel.Session.Findings.All.Where(finding => + string.Equals(finding.RuleId, Rule.Id, StringComparison.Ordinal) + || finding.TargetsPointer($"/Rules/{_ruleIndex}")).ToArray(); + + public bool HasFindings => Findings.Count > 0; + + /// + /// Re-raises change notification for the findings-derived properties without rebuilding this + /// wrapper or its parent collection, so a Validate/Save click never steals focus from whichever + /// field the user was editing. + /// + public void RefreshFindings() + { + OnPropertyChanged(nameof(Findings)); + OnPropertyChanged(nameof(HasFindings)); + foreach (string property in new[] + { + nameof(IdFindings), nameof(HasIdErrors), + nameof(PriorityFindings), nameof(HasPriorityErrors), + nameof(DecisionFindings), nameof(HasDecisionErrors), + nameof(ReasonFindings), nameof(HasReasonErrors), + nameof(PackageNamesFindings), nameof(HasPackageNamesErrors), + nameof(VersionsFindings), nameof(HasVersionsErrors), + nameof(MinVersionFindings), nameof(HasMinVersionErrors), + nameof(MaxVersionFindings), nameof(HasMaxVersionErrors), + }) + { + OnPropertyChanged(property); + } + } + + private IReadOnlyList FindingsFor(params string[] suffixes) => + Findings.Where(finding => suffixes.Any(suffix => + finding.Pointer.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + || finding.Pointer.Contains( + suffix + "/", + StringComparison.OrdinalIgnoreCase))).ToArray(); + + private IReadOnlyList FindingsEndingAt(string suffix) => + Findings.Where(finding => + finding.Pointer.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)).ToArray(); + + private static bool HasErrors(IEnumerable findings) => + findings.Any(finding => finding.IsError); + + private void SetTriState(Action assign, int index) + { + if (index < 0 || index >= PolicyEditorEnumDisplay.TriStates.Length) return; + assign(PolicyEditorEnumDisplay.TriStates[index]); + MarkDirty(); + } + + private void NotifyVersionRangePropertiesChanged() + { + OnPropertyChanged(nameof(MinVersion)); + OnPropertyChanged(nameof(MaxVersion)); + OnPropertyChanged(nameof(IncludePrerelease)); + } + + private void NotifyConstraintPropertiesChanged() + { + OnPropertyChanged(nameof(AllowInteractive)); + OnPropertyChanged(nameof(AllowSkipHashCheck)); + OnPropertyChanged(nameof(AllowPreRelease)); + OnPropertyChanged(nameof(AllowCustomInstallLocation)); + OnPropertyChanged(nameof(AllowedInstallLocationPatterns)); + OnPropertyChanged(nameof(AllowCustomParameters)); + OnPropertyChanged(nameof(AllowedCustomParameters)); + OnPropertyChanged(nameof(AllowedCustomParameterPatterns)); + OnPropertyChanged(nameof(DeniedCustomParameters)); + OnPropertyChanged(nameof(AllowPrePostCommands)); + OnPropertyChanged(nameof(AllowKillBeforeOperation)); + OnPropertyChanged(nameof(AllowUninstallPrevious)); + OnPropertyChanged(nameof(AllowUpgrade)); + } + + private PolicyEditorDraftVersionRange EnsureVersionRange() => + Rule.Match.VersionRange ??= new PolicyEditorDraftVersionRange(); + + private PolicyEditorDraftConstraints EnsureConstraints() => + Rule.Constraints ??= new PolicyEditorDraftConstraints(); + + private void MarkDirty() => _sessionViewModel.NotifyDraftChangedCommand.Execute(null); + + private void SetPriorityError(string? error) + { + if (string.Equals(_priorityError, error, StringComparison.Ordinal)) return; + _priorityError = error; + _sessionViewModel.SetLocalInputError(_priorityErrorKey, error); + OnPropertyChanged(nameof(PriorityError)); + } + + public void Dispose() => _sessionViewModel.SetLocalInputError(_priorityErrorKey, null); + + private static string Join(IEnumerable? values) => + values is null ? "" : string.Join(Environment.NewLine, values); + + private void SetListField(List backing, string? value) + { + backing.Clear(); + if (!string.IsNullOrEmpty(value)) + { + backing.AddRange(value.Split( + ["\r\n", "\n", "\r"], + StringSplitOptions.RemoveEmptyEntries)); + } + + MarkDirty(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs new file mode 100644 index 0000000000..e1a3d617ee --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs @@ -0,0 +1,87 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Produces the fail-closed starting point for a brand-new policy document. Everything the template +/// fixes (policy type, policy format version, rule precedence, default decision, empty rule set) is +/// non-negotiable at creation time; only the caller-supplied identity ( in +/// ) and publisher are free-form, because the editor cannot know them in advance. +/// +public static class PolicyEditorTemplates +{ + public const int ResourceIdMaxLength = 128; + + /// + /// Creates a brand-new draft document: fixed type/version, PriorityThenDeny + /// precedence, a default decision of Deny (fail closed), and no rules. The caller must + /// supply the new policy's and ; both are + /// validated to be non-empty since the write path (external to this domain) requires them. + /// + public static PolicyEditorDraftDocument CreateNew(string id, string publisher) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("A new policy requires a non-empty identifier.", nameof(id)); + } + + if (string.IsNullOrEmpty(publisher)) + { + throw new ArgumentException("A new policy requires a non-empty publisher.", nameof(publisher)); + } + + return new PolicyEditorDraftDocument + { + PolicyFormatVersion = PolicyFormatVersion.Current, + Metadata = new PolicyEditorDraftMetadata + { + Id = id, + Publisher = publisher, + }, + Enforcement = new PolicyEditorDraftEnforcement + { + DefaultDecision = PolicyEditorPolicyContract.DefaultTemplateDecision, + }, + Rules = [], + }; + } + + public static string CreateReplacementId(string activeId) + { + if (!IsValidResourceId(activeId)) + { + throw new ArgumentException( + "The active policy identifier is not a valid resource identifier.", + nameof(activeId)); + } + + const string suffix = "-new"; + int prefixLength = Math.Min(activeId.Length, ResourceIdMaxLength - suffix.Length); + string candidate = activeId[..prefixLength] + suffix; + if (!string.Equals(candidate, activeId, StringComparison.Ordinal)) + { + return candidate; + } + + char replacement = activeId[^1] == '0' ? '1' : '0'; + return activeId[..^1] + replacement; + } + + public static bool IsValidResourceId(string? value) + { + if (string.IsNullOrEmpty(value) + || value.Length > ResourceIdMaxLength + || !IsAsciiLetterOrDigit(value[0])) + { + return false; + } + + return value.AsSpan(1).IndexOfAnyExcept( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-") < 0; + } + + private static bool IsAsciiLetterOrDigit(char value) => + value is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9'; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs new file mode 100644 index 0000000000..3afc3a0729 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs @@ -0,0 +1,130 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Creates blank rules with fresh, contract-valid identifiers. +public static class PolicyRuleFactory +{ + /// + /// Generates a new rule identifier. The format (lowercase hex GUID with a readable prefix) only + /// uses ASCII letters, digits, and hyphens, satisfying the broker's resource-id contract. + /// + public static string CreateRuleId() => $"rule-{Guid.NewGuid():N}"; + + /// + /// Creates a new, empty, disabled rule. Empty match collections are wildcards, so keeping the + /// rule disabled prevents a newly added deny rule from matching every request before it is edited. + /// + public static PolicyEditorDraftRule CreateBlank(string? id = null) => new() + { + Id = id ?? CreateRuleId(), + Enabled = false, + Priority = 0, + Decision = Decision.Deny, + Reason = null, + Match = new PolicyEditorDraftMatch(), + Constraints = null, + }; +} + +/// +/// Pure, UI-independent mutation operations over a rule list, covering add/edit/duplicate(new +/// ID)/enable/disable/delete/reorder/priority. UI actions can target the selected rule instance so +/// temporary duplicate IDs cannot redirect an action to a different row; ID-based overloads remain +/// available for validated programmatic callers. +/// +public static class PolicyRuleListOperations +{ + public static void Add(List rules, PolicyEditorDraftRule rule) + { + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(rule); + EnsureIdIsUnique(rules, rule.Id); + rules.Add(rule); + } + + public static void Edit(List rules, string id, Action mutate) + { + ArgumentNullException.ThrowIfNull(mutate); + mutate(Find(rules, id)); + } + + /// Duplicates a rule, always assigning the copy a new identifier distinct from every + /// existing rule. Returns the new rule's id. + public static string Duplicate(List rules, string id, string? newId = null) + => Duplicate(rules, Find(rules, id), newId); + + public static string Duplicate( + List rules, + PolicyEditorDraftRule rule, + string? newId = null) + { + PolicyEditorDraftRule source = Find(rules, rule); + string generatedId = newId ?? PolicyRuleFactory.CreateRuleId(); + EnsureIdIsUnique(rules, generatedId); + + PolicyEditorDraftRule copy = source.CloneWithNewId(generatedId); + int index = rules.IndexOf(source); + rules.Insert(index + 1, copy); + return generatedId; + } + + public static void SetEnabled(List rules, string id, bool enabled) => + Find(rules, id).Enabled = enabled; + + public static void SetEnabled( + List rules, + PolicyEditorDraftRule rule, + bool enabled) => + Find(rules, rule).Enabled = enabled; + + public static void Delete(List rules, string id) => + rules.Remove(Find(rules, id)); + + public static void Delete(List rules, PolicyEditorDraftRule rule) => + rules.Remove(Find(rules, rule)); + + /// Moves a rule to a new position in document order. is + /// clamped to the valid range. + public static void Move(List rules, string id, int newIndex) + => Move(rules, Find(rules, id), newIndex); + + public static void Move( + List rules, + PolicyEditorDraftRule rule, + int newIndex) + { + rule = Find(rules, rule); + int clamped = Math.Clamp(newIndex, 0, rules.Count - 1); + rules.Remove(rule); + rules.Insert(clamped, rule); + } + + public static void SetPriority(List rules, string id, uint priority) => + Find(rules, id).Priority = priority; + + private static void EnsureIdIsUnique(List rules, string id) + { + if (rules.Any(rule => string.Equals(rule.Id, id, StringComparison.Ordinal))) + { + throw new InvalidOperationException($"A rule with id '{id}' already exists."); + } + } + + private static PolicyEditorDraftRule Find(List rules, string id) + { + ArgumentNullException.ThrowIfNull(rules); + return rules.FirstOrDefault(rule => string.Equals(rule.Id, id, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException($"No rule with id '{id}' exists."); + } + + private static PolicyEditorDraftRule Find( + List rules, + PolicyEditorDraftRule rule) + { + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(rule); + return rules.FirstOrDefault(candidate => ReferenceEquals(candidate, rule)) + ?? throw new KeyNotFoundException("The selected rule no longer exists."); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs new file mode 100644 index 0000000000..160cc063ad --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs @@ -0,0 +1,533 @@ +using System.Text; +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.AgentBroker.PolicyManagement; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A single finding reported by the external (Agent-side) semantic validator, or synthesized locally +/// by for structural/contract failures. is a +/// JSON Pointer (RFC 6901, e.g. /rules/0/match/versions/1) into the raw JSON that was +/// validated; is populated when the finding can be attributed to a specific rule, +/// even if its exact position in the document has since changed. +/// +public sealed record PolicyValidationFinding( + string Pointer, + string? RuleId, + PolicyValidationSeverity Severity, + string Message, + PolicyFindingCode? Code = null, + IReadOnlyDictionary? Arguments = null) +{ + public static PolicyValidationFinding FromShared(PolicyFinding finding) => + CreateBounded(new( + finding.Path ?? "", + finding.RuleId, + MapSeverity(finding.Severity), + PolicyFindingPresentation.Describe(finding.Code, finding.Arguments, finding.Message), + finding.Code, + PolicyFindingPresentation.CopyArguments(finding.Arguments))); + + public static PolicyValidationFinding FromSanitized(BrokerPolicySanitizedFinding finding) => + CreateBounded(new( + finding.Path ?? "", + finding.RuleId, + MapSeverity(finding.Severity), + PolicyFindingPresentation.Describe(finding.Code, finding.Arguments, finding.Message), + finding.Code, + PolicyFindingPresentation.CopyArguments(finding.Arguments))); + + public static PolicyValidationFinding CreateBounded(PolicyValidationFinding finding) => finding with + { + Pointer = PolicyFindingPresentation.SanitizeAgentText( + finding.Pointer, + BrokerPolicyManagementLimits.MaxSanitizedTextLength), + RuleId = string.IsNullOrEmpty(finding.RuleId) + ? null + : PolicyFindingPresentation.SanitizeAgentText( + finding.RuleId, + BrokerPolicyManagementLimits.MaxSanitizedTextLength), + Message = PolicyFindingPresentation.SanitizeAgentText( + finding.Message, + BrokerPolicyManagementLimits.MaxSanitizedTextLength), + Arguments = finding.Arguments is null + ? null + : PolicyFindingPresentation.CopyArguments(finding.Arguments), + }; + + public string SeverityText => CoreTools.Translate(Severity.ToString()); + + public bool IsError => Severity == PolicyValidationSeverity.Error; + + public bool IsWarning => Severity == PolicyValidationSeverity.Warning; + + public string FriendlyLocation => PolicyFindingPresentation.DescribeLocation(Pointer, RuleId); + + public bool HasRawPointer => !string.IsNullOrWhiteSpace(Pointer); + + public string AutomationName => HasRawPointer + ? CoreTools.Translate( + "{0}. Location: {1}. JSON pointer: {2}", + Message, + FriendlyLocation, + Pointer) + : CoreTools.Translate("{0}. Location: {1}", Message, FriendlyLocation); + + public bool TargetsPointer(string pointer) + { + if (string.IsNullOrEmpty(pointer) || string.IsNullOrEmpty(Pointer)) + return false; + + return Pointer.Equals(pointer, StringComparison.OrdinalIgnoreCase) + || (Pointer.StartsWith(pointer, StringComparison.OrdinalIgnoreCase) + && Pointer.Length > pointer.Length + && Pointer[pointer.Length] == '/'); + } + + private static PolicyValidationSeverity MapSeverity(PolicyFindingSeverity severity) => + severity switch + { + PolicyFindingSeverity.Warning => PolicyValidationSeverity.Warning, + PolicyFindingSeverity.Error => PolicyValidationSeverity.Error, + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null), + }; +} + +/// +/// Converts stable Agent finding codes and structured arguments into localized UI text. Generic codes +/// retain a bounded, sanitized Agent detail because that is where value/constraint specifics live. +/// +public static class PolicyFindingPresentation +{ + private const int MaxArgumentEntries = + BrokerPolicyManagementLimits.MaxSanitizedArgumentEntries; + private const int MaxArgumentLength = + BrokerPolicyManagementLimits.MaxSanitizedArgumentValueLength; + private const int MaxFallbackLength = + BrokerPolicyManagementLimits.MaxSanitizedTextLength; + + public static string Describe( + PolicyFindingCode code, + IReadOnlyDictionary? arguments, + string? fallbackMessage) + { + IReadOnlyDictionary copied = CopyArguments(arguments); + return Describe(code, copied, fallbackMessage); + } + + public static string Describe( + PolicyFindingCode code, + IReadOnlyDictionary? arguments, + string? fallbackMessage) => code switch + { + PolicyFindingCode.SchemaViolation => + CoreTools.Translate("The policy draft does not match the required JSON schema."), + PolicyFindingCode.UnknownField => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy draft contains an unknown field."), + fallbackMessage), + PolicyFindingCode.MissingRequiredField => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy draft is missing a required field."), + fallbackMessage), + PolicyFindingCode.InvalidFieldType => + DescribeWithSpecificDetail( + CoreTools.Translate("A policy field has the wrong value type."), + fallbackMessage), + PolicyFindingCode.InvalidFieldValue => + DescribeWithSpecificDetail( + CoreTools.Translate("A policy field has an invalid value."), + fallbackMessage), + PolicyFindingCode.DuplicateRuleId => + CoreTools.Translate("Rule IDs must be unique."), + PolicyFindingCode.IneffectiveBooleanMatch => + CoreTools.Translate("A boolean match must be omitted, true, or false; mixed arrays are invalid."), + PolicyFindingCode.InvalidVersionRange => + DescribeWithSpecificDetail( + CoreTools.Translate("The version range is invalid."), + fallbackMessage), + PolicyFindingCode.EmptyVersionRange => + CoreTools.Translate("The version range does not restrict any versions."), + PolicyFindingCode.InvalidWildcardPattern => + CoreTools.Translate("A wildcard pattern is invalid."), + PolicyFindingCode.ContradictoryConstraints => + CoreTools.Translate("The rule contains contradictory constraints."), + PolicyFindingCode.InvalidValidityInterval => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy validity interval is invalid."), + fallbackMessage), + PolicyFindingCode.UnsupportedPolicyType => + CoreTools.Translate("The policy type is unsupported."), + PolicyFindingCode.UnsupportedPolicyFormatVersion => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy format version is unsupported."), + fallbackMessage), + PolicyFindingCode.AuditModeEnabled => + CoreTools.Translate("Audit mode is enabled; decisions are logged but not enforced."), + PolicyFindingCode.DefaultAllow => + CoreTools.Translate("The default decision is Allow; requests matching no rule are permitted."), + PolicyFindingCode.SensitiveOptionAllowed => + DescribeSensitiveOption(arguments), + _ => SanitizeFallback(fallbackMessage), + }; + + public static string DescribeLocation(string? pointer, string? ruleId) + { + string sanitizedPointer = Sanitize(pointer ?? "", MaxFallbackLength); + string sanitizedRuleId = Sanitize(ruleId ?? "", MaxArgumentLength); + if (string.IsNullOrWhiteSpace(sanitizedPointer)) + return CoreTools.Translate("Policy document"); + + string[] segments = sanitizedPointer + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(DecodePointerSegment) + .ToArray(); + var parts = new List(3); + int index = 0; + if (segments.Length >= 2 + && segments[0].Equals("Rules", StringComparison.OrdinalIgnoreCase) + && int.TryParse(segments[1], out int ruleIndex)) + { + parts.Add(string.IsNullOrWhiteSpace(sanitizedRuleId) + ? CoreTools.Translate("Rule: {0}", ruleIndex + 1) + : CoreTools.Translate("Rule: {0}", $"'{sanitizedRuleId}'")); + index = 2; + } + + for (; index < segments.Length; index++) + { + string segment = segments[index]; + if (int.TryParse(segment, out int itemIndex)) + { + parts.Add(CoreTools.Translate("Item {0}", itemIndex + 1)); + continue; + } + + string? label = FieldLabel(segment); + if (label is not null + && (parts.Count == 0 || !parts[^1].Equals(label, StringComparison.Ordinal))) + { + parts.Add(label); + } + } + + return parts.Count == 0 + ? CoreTools.Translate("Policy document") + : string.Join(" \u00b7 ", parts); + } + + public static IReadOnlyDictionary CopyArguments( + IReadOnlyDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + return new Dictionary(); + + var copied = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair argument in arguments + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxArgumentEntries)) + { + string key = Sanitize(argument.Key, MaxArgumentLength); + string value; + try + { + value = argument.Value.GetRawText(); + } + catch (InvalidOperationException) + { + value = ""; + } + + copied[key] = Sanitize(value, MaxArgumentLength); + } + + return copied; + } + + public static IReadOnlyDictionary CopyArguments( + IReadOnlyDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + return new Dictionary(); + + var copied = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair argument in arguments + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxArgumentEntries)) + { + copied[Sanitize(argument.Key, MaxArgumentLength)] = + Sanitize(argument.Value, MaxArgumentLength); + } + + return copied; + } + + private static string DescribeSensitiveOption(IReadOnlyDictionary? arguments) + { + string? option = ReadJsonString(arguments, "Option"); + string description = option switch + { + "SkipHashCheck" => CoreTools.Translate("An enabled Allow rule permits skipping package hash verification."), + "PreRelease" => CoreTools.Translate("An enabled Allow rule permits prerelease package versions."), + "AllowCustomInstallLocation" => CoreTools.Translate("An enabled Allow rule permits custom install locations."), + "AllowCustomParameters" => CoreTools.Translate("An enabled Allow rule permits custom command-line parameters."), + "AllowPrePostCommands" => CoreTools.Translate("An enabled Allow rule permits pre-operation or post-operation commands."), + "AllowKillBeforeOperation" => CoreTools.Translate("An enabled Allow rule permits killing processes before an operation."), + "AllowUninstallPrevious" => CoreTools.Translate("An enabled Allow rule permits uninstalling a previous version."), + _ => CoreTools.Translate("An enabled Allow rule permits a sensitive option."), + }; + + string[] restrictions = + [ + FormatRestriction(arguments, "AllowedInstallLocationPatterns", "Allowed install location patterns"), + FormatRestriction(arguments, "AllowedCustomParameters", "Allowed custom parameters"), + FormatRestriction(arguments, "AllowedCustomParameterPatterns", "Allowed custom parameter patterns"), + FormatRestriction(arguments, "DeniedCustomParameters", "Denied custom parameters"), + ]; + string restrictionText = string.Join( + "; ", + restrictions.Where(value => !string.IsNullOrEmpty(value))); + return restrictionText.Length == 0 + ? description + : $"{description} {CoreTools.Translate("Restrictions: {0}", restrictionText)}"; + } + + private static string DescribeWithSpecificDetail(string summary, string? fallbackMessage) + { + string detail = Sanitize(fallbackMessage ?? "", MaxArgumentLength); + if (string.IsNullOrWhiteSpace(detail) + || detail.Equals(summary, StringComparison.OrdinalIgnoreCase)) + { + return summary; + } + + return CoreTools.Translate("{0} Detail: {1}", summary, detail); + } + + private static string DecodePointerSegment(string segment) => + segment.Replace("~1", "/", StringComparison.Ordinal) + .Replace("~0", "~", StringComparison.Ordinal); + + private static string? FieldLabel(string segment) => + segment.ToUpperInvariant() switch + { + "$SCHEMA" => CoreTools.Translate("Schema"), + "POLICYTYPE" => CoreTools.Translate("Policy type"), + "POLICYFORMATVERSION" => CoreTools.Translate("Policy format version"), + "METADATA" => CoreTools.Translate("Metadata"), + "ID" => CoreTools.Translate("ID"), + "PUBLISHER" => CoreTools.Translate("Publisher"), + "DESCRIPTION" => CoreTools.Translate("Description"), + "SUPPORTURL" => CoreTools.Translate("Support URL"), + "VALIDFROM" => CoreTools.Translate("Valid from"), + "VALIDUNTIL" => CoreTools.Translate("Valid until"), + "ENFORCEMENT" => CoreTools.Translate("Enforcement"), + "DEFAULTDECISION" => CoreTools.Translate("Default decision"), + "RULEPRECEDENCE" => CoreTools.Translate("Rule precedence"), + "AUDITMODE" => CoreTools.Translate("Audit mode"), + "RULES" => null, + "ENABLED" => CoreTools.Translate("Enabled"), + "PRIORITY" => CoreTools.Translate("Priority"), + "DECISION" => CoreTools.Translate("Decision"), + "REASON" => CoreTools.Translate("Reason"), + "MATCH" => CoreTools.Translate("Match criteria"), + "OPERATIONS" => CoreTools.Translate("Operations"), + "MANAGERS" => CoreTools.Translate("Package managers"), + "SOURCES" => CoreTools.Translate("Sources"), + "PACKAGEIDENTIFIERS" => CoreTools.Translate("Package identifiers"), + "PACKAGENAMES" => CoreTools.Translate("Package names"), + "VERSIONS" => CoreTools.Translate("Versions"), + "VERSIONRANGE" => CoreTools.Translate("Version range"), + "MINVERSION" => CoreTools.Translate("Minimum version"), + "MAXVERSION" => CoreTools.Translate("Maximum version"), + "INCLUDEPRERELEASE" => CoreTools.Translate("Include prerelease versions"), + "SCOPES" => CoreTools.Translate("Scopes"), + "ARCHITECTURES" => CoreTools.Translate("Architectures"), + "ELEVATION" => CoreTools.Translate("Elevation"), + "INTERACTIVE" => CoreTools.Translate("Interactive"), + "SKIPHASHCHECK" => CoreTools.Translate("Skip hash check"), + "PRERELEASE" => CoreTools.Translate("Prerelease"), + "HASCUSTOMPARAMETERS" => CoreTools.Translate("Has custom parameters"), + "HASCUSTOMINSTALLLOCATION" => CoreTools.Translate("Has custom install location"), + "HASPREPOSTCOMMANDS" => CoreTools.Translate("Has pre/post commands"), + "HASKILLBEFOREOPERATION" => CoreTools.Translate("Has kill-before-operation"), + "HASUNINSTALLPREVIOUS" => CoreTools.Translate("Has uninstall previous"), + "CONSTRAINTS" => CoreTools.Translate("Constraints"), + _ => Humanize(segment), + }; + + private static string Humanize(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + + var result = new StringBuilder(value.Length + 4); + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + if (index > 0 && char.IsUpper(character) && char.IsLower(value[index - 1])) + result.Append(' '); + result.Append(character); + } + + return Sanitize(result.ToString(), MaxArgumentLength); + } + + private static string FormatRestriction( + IReadOnlyDictionary? arguments, + string key, + string label) + { + if (arguments is null + || !arguments.TryGetValue(key, out string? value) + || string.IsNullOrWhiteSpace(value)) + { + return ""; + } + + return $"{CoreTools.Translate(label)}: {Sanitize(value, MaxArgumentLength)}"; + } + + private static string? ReadJsonString( + IReadOnlyDictionary? arguments, + string key) + { + if (arguments is null || !arguments.TryGetValue(key, out string? raw)) + return null; + + try + { + using JsonDocument document = JsonDocument.Parse(raw); + return document.RootElement.ValueKind == JsonValueKind.String + ? Sanitize(document.RootElement.GetString() ?? "", MaxArgumentLength) + : null; + } + catch (JsonException) + { + return null; + } + } + + private static string SanitizeFallback(string? message) + { + string sanitized = Sanitize(message ?? "", MaxFallbackLength); + return string.IsNullOrWhiteSpace(sanitized) + ? CoreTools.Translate("Devolutions Agent reported an unrecognized policy finding.") + : sanitized; + } + + public static string SanitizeAgentText(string? value, int maxLength) => + Sanitize(value ?? "", maxLength); + + private static string Sanitize(string value, int maxLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxLength); + + var result = new StringBuilder(Math.Min(value.Length, maxLength)); + int scalarCount = 0; + foreach (Rune rune in value.EnumerateRunes()) + { + if (Rune.IsControl(rune)) + continue; + if (scalarCount == maxLength) + break; + + result.Append(rune); + scalarCount++; + } + + return result.ToString(); + } +} + +/// +/// Indexes a flat list of for quick lookup by JSON Pointer or by +/// rule ID, so the UI can highlight the right field/rule without re-scanning the whole finding list on +/// every render. +/// +public sealed class PolicyEditorFindingIndex +{ + public const int MaxDisplayedFindings = + BrokerPolicyManagementLimits.MaxSanitizedFindings; + + private static readonly IReadOnlyList Empty = []; + + public IReadOnlyList All { get; } + public bool FindingsTruncated { get; } + public int OmittedFindingCount { get; } + + private readonly IReadOnlyDictionary> _byPointer; + private readonly IReadOnlyDictionary> _byRuleId; + + private PolicyEditorFindingIndex( + IReadOnlyList all, + IReadOnlyDictionary> byPointer, + IReadOnlyDictionary> byRuleId, + int omittedFindingCount) + { + All = all; + _byPointer = byPointer; + _byRuleId = byRuleId; + OmittedFindingCount = omittedFindingCount; + FindingsTruncated = omittedFindingCount > 0; + } + + public static PolicyEditorFindingIndex Build( + IReadOnlyList findings, + int omittedFindingCount = 0) + { + ArgumentNullException.ThrowIfNull(findings); + ArgumentOutOfRangeException.ThrowIfNegative(omittedFindingCount); + + int totalOmitted = omittedFindingCount; + int retainedLimit = findings.Count + totalOmitted > MaxDisplayedFindings + ? MaxDisplayedFindings - 1 + : MaxDisplayedFindings; + if (findings.Count > retainedLimit) + { + totalOmitted += findings.Count - retainedLimit; + } + + var all = new List(MaxDisplayedFindings); + for (int index = 0; index < Math.Min(findings.Count, retainedLimit); index++) + { + all.Add(PolicyValidationFinding.CreateBounded(findings[index])); + } + + if (totalOmitted > 0) + { + all.Add(new PolicyValidationFinding( + "", + null, + PolicyValidationSeverity.Warning, + CoreTools.Translate( + "{0} additional validation finding(s) were omitted.", + totalOmitted))); + } + + Dictionary> byPointer = all + .GroupBy(finding => finding.Pointer, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => [.. group], + StringComparer.Ordinal); + + Dictionary> byRuleId = all + .Where(finding => finding.RuleId is not null) + .GroupBy(finding => finding.RuleId!, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => [.. group], + StringComparer.Ordinal); + + return new PolicyEditorFindingIndex(all, byPointer, byRuleId, totalOmitted); + } + + public IReadOnlyList ForPointer(string pointer) => + _byPointer.TryGetValue(pointer, out IReadOnlyList? findings) ? findings : Empty; + + public IReadOnlyList ForRule(string ruleId) => + _byRuleId.TryGetValue(ruleId, out IReadOnlyList? findings) ? findings : Empty; +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/PolicyHelp.cs b/src/UniGetUI.Avalonia/Views/Controls/PolicyHelp.cs new file mode 100644 index 0000000000..1147f0e1ca --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/PolicyHelp.cs @@ -0,0 +1,38 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Media; + +namespace UniGetUI.Avalonia.Views.Controls; + +/// Applies the same localized policy help to a wrapped tooltip and accessibility help text. +public static class PolicyHelp +{ + public static readonly AttachedProperty TextProperty = + AvaloniaProperty.RegisterAttached("Text", typeof(PolicyHelp)); + + public static void SetText(Control control, string? value) => + control.SetValue(TextProperty, value); + + public static string? GetText(Control control) => + control.GetValue(TextProperty); + + static PolicyHelp() + { + TextProperty.Changed.AddClassHandler((control, change) => + { + string? text = change.GetNewValue(); + AutomationProperties.SetHelpText(control, text); + ToolTip.SetTip( + control, + string.IsNullOrWhiteSpace(text) + ? null + : new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 420, + }); + }); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs b/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs new file mode 100644 index 0000000000..e2be64885d --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using AvaloniaEdit; + +namespace UniGetUI.Avalonia.Views.Controls; + +public sealed class PolicyJsonEditor : TextEditor +{ + protected override Type StyleKeyOverride => typeof(TextEditor); + + public PolicyJsonEditor() + { + ShowLineNumbers = true; + WordWrap = false; + FontFamily = new FontFamily("Cascadia Mono,Consolas,Menlo,monospace"); + FontSize = 12; + Padding = new Thickness(8); + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + } +} diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs index 5e3cd0f0dc..d49290982a 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs @@ -66,4 +66,10 @@ private void Complete(bool result) Result = result; Close(); } + + internal void CancelPendingChoice() + { + RequireChoice = false; + Close(); + } } diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 67d1a6ca61..3c359e90aa 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -121,7 +121,7 @@ public partial class MainWindow : Window private bool _maxButtonPressed; private TrayService? _trayService; private bool _allowClose; - private int _isQuitting; + private readonly ApplicationShutdownCoordinator _shutdownCoordinator = new(); // Saved outer size (DIPs) awaiting a native, exact restore in OnOpened on Windows. private double _pendingRestoreWidth; @@ -1857,23 +1857,28 @@ public void ShowFromTray() AvaloniaOperationRegistry.PromptPendingShortcutsIfAny(); } - public bool IsQuitting => Interlocked.CompareExchange(ref _isQuitting, 0, 0) == 1; + public bool IsQuitting => _shutdownCoordinator.IsQuitting; - public void QuitApplication() - { - if (Interlocked.Exchange(ref _isQuitting, 1) == 1) - return; + public void QuitApplication() => _ = RequestQuitApplicationAsync(); + internal Task RequestQuitApplicationAsync(Action? onAuthorized = null) => + _shutdownCoordinator.RequestAsync( + () => ViewModel.CanShutdownAsync(), + ShutdownApplicationAsync, + onAuthorized); + + private async Task ShutdownApplicationAsync() + { _allowClose = true; ReleaseWindowResources(); if (IsVisible) Hide(); - _ = QuitApplicationAsync(); + await StopAndExitApplicationAsync(); } - private static async Task QuitApplicationAsync() + private static async Task StopAndExitApplicationAsync() { Logger.Warn("Quitting UniGetUI"); try diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml index 00524aa01e..9f15b36bf0 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml @@ -4,6 +4,7 @@ xmlns:automation="clr-namespace:Avalonia.Automation;assembly=Avalonia.Controls" xmlns:controls="using:UniGetUI.Avalonia.Views.Controls" xmlns:vm="using:UniGetUI.Avalonia.ViewModels.Pages.SettingsPages" + xmlns:pvm="using:UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor" xmlns:t="using:UniGetUI.Avalonia.MarkupExtensions" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" @@ -37,22 +38,147 @@ + +