├── .github └── workflows │ └── release.yml ├── .gitignore ├── AddToPath.code-workspace ├── AddToPath.sln ├── CONTRIBUTING.md ├── FodyWeavers.xml ├── FodyWeavers.xsd ├── GitVersion.yml ├── LICENSE ├── README.md └── src ├── AddToPath ├── AddToPath.csproj ├── Images │ ├── AddToPath.ico │ ├── AddToPath.png │ └── Screenshot1.png ├── MainForm.cs ├── PathsDialog.cs ├── Program.cs ├── Properties │ ├── Resources.Designer.cs │ └── Resources.resx └── app.manifest └── a2p ├── Program.cs └── a2p.csproj /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup .NET 21 | uses: actions/setup-dotnet@v4 22 | with: 23 | dotnet-version: 8.0.x 24 | 25 | - name: Get Version 26 | id: get_version 27 | shell: pwsh 28 | run: | 29 | $version = "${{ github.ref_name }}" -replace '^v','' 30 | echo "version=$version" >> $env:GITHUB_OUTPUT 31 | 32 | - name: Build 33 | run: dotnet build -c Release /p:Version=${{ steps.get_version.outputs.version }} 34 | 35 | - name: Create Release ZIP 36 | shell: pwsh 37 | run: | 38 | $version = "${{ steps.get_version.outputs.version }}" 39 | $zipName = "AddToPath-v${version}-win-x64.zip" 40 | # First check if files exist 41 | Get-ChildItem -Path "bin/Release" -ErrorAction Continue 42 | # Create a temporary directory for the release 43 | New-Item -ItemType Directory -Path "release-temp" -Force 44 | # Copy executables 45 | Copy-Item "bin/Release/*" -Destination "release-temp" -Recurse 46 | # Process and include README files 47 | if (Test-Path "README.md") { 48 | # Create README.md with absolute image URLs 49 | $readme = Get-Content "README.md" -Raw 50 | $repoUrl = "https://raw.githubusercontent.com/nsxdavid/AddToPath/${{ github.sha }}" 51 | $readme = $readme -replace '(!\[.*?\])\((src/.*?)\)', "`$1($repoUrl/`$2)" 52 | Set-Content -Path "release-temp/README.md" -Value $readme -Encoding UTF8 53 | 54 | # Create plain text README.txt 55 | # Remove HTML tags and alignment 56 | $readme = $readme -replace '<[^>]+>', '' ` 57 | -replace '\s*]*>\s*', "`r`n" ` 58 | -replace '\s*

\s*', "`r`n" 59 | # Remove image references and badges 60 | $readme = $readme -replace '!\[[^\]]*\]\([^\)]+\)', '' ` 61 | -replace '\[\![^\]]*\]\([^\)]+\)', '' 62 | # Remove Markdown formatting 63 | $readme = $readme -replace '#+ ', '' ` 64 | -replace '\[([^\]]+)\]\([^\)]+\)', '$1' ` 65 | -replace '\*\*([^\*]+)\*\*', '$1' ` 66 | -replace '`([^`]+)`', '$1' ` 67 | -replace '_([^_]+)_', '$1' ` 68 | -replace '\*([^\*]+)\*', '$1' 69 | # Fix code blocks 70 | $readme = $readme -replace '```\w*\s*', '' ` 71 | -replace '```\s*', '' 72 | # Clean up multiple blank lines and trim 73 | $readme = $readme -replace '(\r?\n\s*){3,}', "`r`n`r`n" 74 | $readme = $readme.Trim() 75 | Set-Content -Path "release-temp/README.txt" -Value $readme -Encoding UTF8 76 | } 77 | if (Test-Path "LICENSE") { 78 | Copy-Item "LICENSE" -Destination "release-temp/LICENSE.txt" 79 | } 80 | # Create ZIP with all files 81 | Compress-Archive -Path "release-temp/*" -DestinationPath $zipName -Force 82 | # Cleanup 83 | Remove-Item "release-temp" -Recurse -Force 84 | 85 | - name: Generate Release Notes 86 | id: release_notes 87 | shell: pwsh 88 | run: | 89 | $version = "${{ steps.get_version.outputs.version }}" 90 | $notes = @" 91 | ## AddToPath v${version} 92 | 93 | ### New Features 94 | - 95 | 96 | ### Improvements 97 | - 98 | 99 | ### Bug Fixes 100 | - 101 | 102 | ### Installation Instructions 103 | 1. Download `AddToPath-v${version}-win-x64.zip` from [releases](https://github.com/nsxdavid/AddToPath/releases) page, Assets section 104 | 2. Extract the ZIP file to any location 105 | 3. Run `AddToPath.exe` from the extracted folder 106 | "@ 107 | 108 | $notes | Out-File release_notes.md -Encoding UTF8 109 | 110 | - name: Create GitHub Release 111 | uses: softprops/action-gh-release@v1 112 | with: 113 | draft: true 114 | body_path: release_notes.md 115 | files: AddToPath-v${{ steps.get_version.outputs.version }}-win-x64.zip 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build results 2 | [Dd]ebug/ 3 | [Dd]ebugPublic/ 4 | [Rr]elease/ 5 | [Rr]eleases/ 6 | x64/ 7 | x86/ 8 | [Ww][Ii][Nn]32/ 9 | [Aa][Rr][Mm]/ 10 | [Aa][Rr][Mm]64/ 11 | bld/ 12 | [Bb]in/ 13 | [Oo]bj/ 14 | [Ll]og/ 15 | [Ll]ogs/ 16 | 17 | # Visual Studio files 18 | .vs/ 19 | *.user 20 | *.userosscache 21 | *.sln.docstates 22 | 23 | # ReSharper 24 | _ReSharper*/ 25 | *.[Rr]e[Ss]harper 26 | *.DotSettings.user 27 | 28 | # Visual Studio code coverage results 29 | *.coverage 30 | *.coveragexml 31 | 32 | # NuGet Packages 33 | *.nupkg 34 | # NuGet Symbol Packages 35 | *.snupkg 36 | # The packages folder can be ignored because of Package Restore 37 | **/[Pp]ackages/* 38 | # except build/, which is used as an MSBuild target. 39 | !**/[Pp]ackages/build/ 40 | *.zip 41 | -------------------------------------------------------------------------------- /AddToPath.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /AddToPath.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.5.2.0 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{450E05F2-523B-4FD2-8DC1-7785851321F7}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AddToPath", "src\AddToPath\AddToPath.csproj", "{78E3074A-2FD6-410D-AB7C-BD64BE1F6273}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "a2p", "src\a2p\a2p.csproj", "{A99B06D1-14C4-4B38-A121-F25280DF3560}" 10 | EndProject 11 | Global 12 | GlobalSection(SolutionProperties) = preSolution 13 | HideSolutionNode = FALSE 14 | EndGlobalSection 15 | GlobalSection(ExtensibilityGlobals) = postSolution 16 | SolutionGuid = {578862AE-6A54-4EB0-B2B3-8461E349D6E7} 17 | EndGlobalSection 18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 | Debug|Any CPU = Debug|Any CPU 20 | Debug|x64 = Debug|x64 21 | Debug|x86 = Debug|x86 22 | Release|Any CPU = Release|Any CPU 23 | Release|x64 = Release|x64 24 | Release|x86 = Release|x86 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Debug|x64.ActiveCfg = Debug|Any CPU 30 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Debug|x64.Build.0 = Debug|Any CPU 31 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Debug|x86.ActiveCfg = Debug|Any CPU 32 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Debug|x86.Build.0 = Debug|Any CPU 33 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Release|x64.ActiveCfg = Release|Any CPU 36 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Release|x64.Build.0 = Release|Any CPU 37 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Release|x86.ActiveCfg = Release|Any CPU 38 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273}.Release|x86.Build.0 = Release|Any CPU 39 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Debug|x64.ActiveCfg = Debug|Any CPU 42 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Debug|x64.Build.0 = Debug|Any CPU 43 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Debug|x86.ActiveCfg = Debug|Any CPU 44 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Debug|x86.Build.0 = Debug|Any CPU 45 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Release|x64.ActiveCfg = Release|Any CPU 48 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Release|x64.Build.0 = Release|Any CPU 49 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Release|x86.ActiveCfg = Release|Any CPU 50 | {A99B06D1-14C4-4B38-A121-F25280DF3560}.Release|x86.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {78E3074A-2FD6-410D-AB7C-BD64BE1F6273} = {450E05F2-523B-4FD2-8DC1-7785851321F7} 54 | {A99B06D1-14C4-4B38-A121-F25280DF3560} = {450E05F2-523B-4FD2-8DC1-7785851321F7} 55 | EndGlobalSection 56 | EndGlobal 57 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to AddToPath 2 | 3 | ## Code of Conduct 4 | 5 | ### Our Pledge 6 | We are committed to making participation in the AddToPath project a harassment-free experience for everyone, regardless of experience level, age, disability, ethnicity, gender identity and expression, nationality, personal appearance, race, religion, or sexual orientation. 7 | 8 | ### Our Standards 9 | Examples of behavior that contributes to creating a positive environment include: 10 | - Using welcoming and inclusive language 11 | - Being respectful of differing viewpoints and experiences 12 | - Gracefully accepting constructive criticism 13 | - Focusing on what is best for the community 14 | - Showing empathy towards other community members 15 | 16 | Examples of unacceptable behavior include: 17 | - The use of sexualized language or imagery 18 | - Trolling, insulting/derogatory comments, and personal or political attacks 19 | - Public or private harassment 20 | - Publishing others' private information without explicit permission 21 | - Other conduct which could reasonably be considered inappropriate 22 | 23 | ### Enforcement 24 | Project maintainers are responsible for clarifying and enforcing these standards. They have the right and responsibility to remove, edit, or reject comments, commits, code, issues, and other contributions that are not aligned with this Code of Conduct. 25 | 26 | ### Reporting 27 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue with the label "Code of Conduct". All complaints will be reviewed and investigated promptly and fairly. 28 | 29 | ## Contribution Process 30 | 31 | ### Issues 32 | 1. **Search First**: Before creating a new issue, search existing issues to avoid duplicates 33 | 2. **Issue Types**: 34 | - Bug Report: Describe the bug, steps to reproduce, expected vs actual behavior 35 | - Feature Request: Describe the feature, its use case, and potential implementation 36 | - Question: For general questions about usage or development 37 | 38 | ### Pull Requests 39 | 1. **Fork & Branch**: 40 | - Fork the repository 41 | - Create a branch with a descriptive name: 42 | - `feature/description` for new features 43 | - `fix/description` for bug fixes 44 | - `docs/description` for documentation changes 45 | - `refactor/description` for code refactoring 46 | 47 | 2. **Commit Guidelines**: 48 | - Write clear, descriptive commit messages 49 | - Optional: Use conventional commits format for structured changes: 50 | ``` 51 | type: description 52 | ``` 53 | - Common types when used: `feat`, `fix`, `docs`, `refactor` 54 | - Keep commits focused on single changes 55 | - Reference issues in commit messages when applicable: "fixes #123" 56 | 57 | 3. **Development**: 58 | - Write clear, commented, and testable code 59 | - Follow existing code style and patterns 60 | - Update documentation if needed 61 | - Test your changes thoroughly 62 | 63 | 4. **Pull Request Process**: 64 | - Create a PR against the `main` branch 65 | - Use the PR template if provided 66 | - Include: 67 | - Clear description of changes 68 | - Screenshots for UI changes 69 | - Steps to test the changes 70 | - Keep PRs focused - one feature/fix per PR 71 | - Respond to review comments promptly 72 | 73 | 5. **Code Review**: 74 | - All PRs require review before merging 75 | - Address review feedback in new commits 76 | - Maintainers may request changes or provide suggestions 77 | - Once approved, maintainers will merge the PR 78 | 79 | ### Communication 80 | - Keep discussions focused and professional 81 | - Provide context and examples when asking questions 82 | - Tag relevant maintainers when needed 83 | - Be patient - maintainers will respond as time permits 84 | 85 | ## Development Setup 86 | 87 | ### Prerequisites 88 | - Visual Studio 2022 or Visual Studio Code 89 | - .NET SDK 8.0 or later (for building) 90 | - .NET Framework 4.7.2 Developer Pack (for debugging) 91 | 92 | ### Framework Choice 93 | - Project targets .NET Framework 4.7.2 specifically because: 94 | - It's included by default in Windows 10 (since version 1803, April 2018) 95 | - Users don't need to install any additional runtimes 96 | - Provides maximum compatibility with Windows 10 and newer systems 97 | - All required dependencies are available in the base framework 98 | 99 | ### Building the Project 100 | ```powershell 101 | dotnet build -c Release 102 | ``` 103 | 104 | ## Project Structure 105 | 106 | ### Solution Layout 107 | ``` 108 | AddToPath/ 109 | ├── src/ # Source code directory 110 | │ ├── AddToPath/ # GUI application project 111 | │ └── a2p/ # CLI tool project 112 | ├── bin/ # Shared build output 113 | │ ├── Debug/ # Debug builds 114 | │ │ ├── AddToPath.exe # GUI executable 115 | │ │ └── a2p.exe # CLI executable 116 | │ └── Release/ # Release builds 117 | └── ... 118 | ``` 119 | 120 | ### Dependencies 121 | - **[Costura.Fody](https://github.com/Fody/Costura)**: Embeds all DLLs into the executable at build time 122 | - Makes both tools completely self-contained 123 | - No external dependencies needed - everything is in the exe 124 | - Currently embeds: 125 | - `System.Resources.Extensions.dll` (needed for resource handling in SDK-style projects) 126 | - All referenced .NET Framework assemblies are available on Windows 10+ by default 127 | - Note: While Costura.Fody is in maintenance mode, it remains the best option for .NET Framework projects 128 | targeting Windows. For .NET Core 3.0+ projects, consider using [single-file executables](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0) 129 | instead. 130 | 131 | ### Resource Management 132 | - Application icon (`AddToPath.ico`) is embedded in the executable 133 | - Icon configuration in `AddToPath.csproj`: 134 | ```xml 135 | 136 | Images\AddToPath.ico 137 | 138 | 139 | 140 | 141 | 142 | ``` 143 | 144 | ### Version Management 145 | - Uses [GitVersion](https://github.com/GitTools/GitVersion) for semantic versioning 146 | - Configuration in `GitVersion.yml` 147 | - Version is automatically determined from Git history 148 | - Tagged commits (e.g., `v1.0.0`) trigger GitHub release workflow 149 | 150 | ### Release Process 151 | 1. GitHub Actions workflow in `.github/workflows/release.yml` 152 | 2. Triggered by pushing a tag starting with 'v' 153 | 3. Creates a draft release containing: 154 | - Single executable with embedded resources 155 | - README.md 156 | - LICENSE 157 | 158 | ### Installation Process 159 | - The GUI application (`AddToPath.exe`) handles installation for both tools 160 | - During installation: 161 | 1. Both executables are copied to `%ProgramFiles%\AddToPath\` 162 | 2. The install directory is added to system PATH 163 | 3. Context menu entries are created for the GUI tool 164 | - The CLI tool (`a2p.exe`) becomes available from any terminal after installation 165 | 166 | ### Testing 167 | - Test on a clean Windows 10+ machine to verify: 168 | - No missing dependencies 169 | - UAC elevation works correctly 170 | - Context menu integration functions 171 | - PATH modifications succeed 172 | - CLI tool is accessible from PATH 173 | 174 | ## Security Guidelines 175 | 176 | ### Reporting Security Issues 177 | - **Do NOT report security vulnerabilities through public GitHub issues** 178 | - Instead, report them privately through [GitHub's Security Advisory feature](https://github.com/nsxdavid/AddToPath/security/advisories/new) 179 | - The maintainers will be notified and can privately discuss the issue with you 180 | - Please provide detailed information about the vulnerability and steps to reproduce 181 | - Once the issue is addressed, we'll coordinate the public disclosure 182 | 183 | ### Security Considerations When Contributing 184 | 1. **Elevated Permissions**: 185 | - Any code that requires admin rights must be clearly marked 186 | - Minimize the scope of elevated operations 187 | - Always verify user intent before executing privileged operations 188 | 189 | 2. **File System Operations**: 190 | - Validate all file paths before modification 191 | - Use secure file operation practices 192 | - Be cautious with file permissions 193 | 194 | 3. **Registry Operations**: 195 | - Validate registry paths before modification 196 | - Only modify necessary registry keys 197 | - Handle registry access errors gracefully 198 | 199 | 4. **Input Validation**: 200 | - Validate all user input 201 | - Sanitize file paths and registry keys 202 | - Handle invalid input gracefully 203 | 204 | ### Best Practices for Security 205 | - Follow the principle of least privilege 206 | - Add appropriate error handling for security-sensitive operations 207 | - Document any security-relevant changes in pull requests 208 | - If in doubt about security implications, ask in the PR discussion 209 | 210 | ## Best Practices 211 | 1. Always build and test in Release configuration before committing 212 | 2. Verify both executables work standalone before pushing a release tag 213 | 3. Keep the distribution clean - all necessary code should be embedded in the exes 214 | 4. Test PATH modifications on both user and system level 215 | 5. Remember to run with admin rights when testing system PATH changes 216 | 6. Test both GUI and CLI functionality after making changes 217 | 7. Context menu organization: 218 | - Menu items are ordered by registry key names (alphabetical sorting) 219 | - Use numbered prefixes (e.g., "1_AddToPath") to control menu order 220 | - Keep display names user-friendly using MUIVerb values 221 | 222 | ### Build Output Directory 223 | - Only executables are copied (no dependencies needed - they're embedded) 224 | - This setup serves multiple purposes: 225 | 1. During development: 226 | - Makes it easy to find and run the latest builds 227 | - Ensures the GUI installer can find the CLI tool for installation 228 | - Simulates the installed state where both tools are in the same directory 229 | 2. For releases: 230 | - Provides a clean directory with just the executables 231 | - Simplifies the release process by having all artifacts in one place 232 | - No dependencies to manage - everything is embedded 233 | -------------------------------------------------------------------------------- /FodyWeavers.xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | -------------------------------------------------------------------------------- /FodyWeavers.xsd: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks 13 | 14 | 15 | 16 | 17 | A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. 18 | 19 | 20 | 21 | 22 | A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks 23 | 24 | 25 | 26 | 27 | A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. 28 | 29 | 30 | 31 | 32 | A list of unmanaged 32 bit assembly names to include, delimited with line breaks. 33 | 34 | 35 | 36 | 37 | A list of unmanaged 64 bit assembly names to include, delimited with line breaks. 38 | 39 | 40 | 41 | 42 | The order of preloaded assemblies, delimited with line breaks. 43 | 44 | 45 | 46 | 47 | 48 | This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file. 49 | 50 | 51 | 52 | 53 | Controls if .pdbs for reference assemblies are also embedded. 54 | 55 | 56 | 57 | 58 | Controls if runtime assemblies are also embedded. 59 | 60 | 61 | 62 | 63 | Controls whether the runtime assemblies are embedded with their full path or only with their assembly name. 64 | 65 | 66 | 67 | 68 | Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option. 69 | 70 | 71 | 72 | 73 | As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off. 74 | 75 | 76 | 77 | 78 | Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code. 79 | 80 | 81 | 82 | 83 | Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior. 84 | 85 | 86 | 87 | 88 | A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with | 89 | 90 | 91 | 92 | 93 | A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |. 94 | 95 | 96 | 97 | 98 | A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with | 99 | 100 | 101 | 102 | 103 | A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |. 104 | 105 | 106 | 107 | 108 | A list of unmanaged 32 bit assembly names to include, delimited with |. 109 | 110 | 111 | 112 | 113 | A list of unmanaged 64 bit assembly names to include, delimited with |. 114 | 115 | 116 | 117 | 118 | The order of preloaded assemblies, delimited with |. 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. 127 | 128 | 129 | 130 | 131 | A comma-separated list of error codes that can be safely ignored in assembly verification. 132 | 133 | 134 | 135 | 136 | 'false' to turn off automatic generation of the XML Schema file. 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: Mainline 2 | assembly-versioning-scheme: MajorMinorPatch 3 | assembly-file-versioning-scheme: MajorMinorPatch 4 | tag-prefix: 'v' 5 | continuous-delivery-fallback-tag: '' 6 | major-version-bump-message: '\+semver:\s?(breaking|major)' 7 | minor-version-bump-message: '\+semver:\s?(feature|minor)' 8 | patch-version-bump-message: '\+semver:\s?(fix|patch)' 9 | no-bump-message: '\+semver:\s?(none|skip)' 10 | commit-message-incrementing: Enabled 11 | 12 | branches: 13 | main: 14 | regex: ^master$|^main$ 15 | is-mainline: true 16 | source-branches: [] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 David Whatley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AddToPath 2 | 3 |

4 | AddToPath Logo 5 |

6 | 7 |

8 | GitHub release 9 | License 10 | Windows 11 |

12 | 13 | A Windows utility for managing your PATH environment variable through both a context menu and command line interface. 14 | 15 |

16 | AddToPath Context Menu 17 |
18 | AddToPath context menu integration in Windows Explorer 19 |

20 | 21 | ## Table of Contents 22 | - [Requirements](#requirements) 23 | - [Quick Start](#quick-start) 24 | - [Features](#features) 25 | - [Installation](#installation) 26 | - [CLI Usage](#cli-usage) 27 | - [Uninstall](#uninstall) 28 | - [Troubleshooting](#troubleshooting) 29 | - [Development](#development) 30 | - [License](#license) 31 | 32 | ## Requirements 33 | 34 | - Windows 10 or later 35 | - .NET Framework 4.7.2 (pre-installed on Windows 10) 36 | - Administrator rights for system PATH modifications 37 | 38 | ## Quick Start 39 | 40 | 1. [Download latest release](https://github.com/nsxdavid/AddToPath/releases/latest) 41 | 2. Run `AddToPath.exe` as administrator 42 | 3. Click "Install Tools" 43 | 4. Right-click any folder → Path → Add to PATH 44 | 45 | ## Features 46 | 47 | - Two ways to manage PATH: 48 | 49 | 1. Context Menu (GUI) 50 | - Right-click any folder and use the "Path" menu 51 | - Add folders to system or user PATH 52 | - Remove folders from PATH 53 | - Check if folders are in PATH 54 | - View all PATH entries 55 | 56 | 2. Command Line (CLI) 57 | - Use `a2p` command from any terminal 58 | - Simple commands for PATH management: 59 | ```powershell 60 | # Add current directory to user PATH 61 | a2p add . 62 | 63 | # Add a directory to system PATH (needs admin) 64 | a2p add -s "C:\Tools" 65 | 66 | # Remove from user PATH 67 | a2p remove "C:\Tools" 68 | 69 | # Check if directory is in PATH 70 | a2p check "C:\Tools" 71 | ``` 72 | 73 | - No external dependencies - everything is embedded 74 | - UAC elevation for admin operations 75 | - Works with both user and system PATH 76 | - Changes take effect immediately in new terminals 77 | - Includes scripts to refresh PATH in existing terminals 78 | 79 | ## Installation 80 | 81 | 1. Download the [latest release](https://github.com/nsxdavid/AddToPath/releases/latest) 82 | 2. Run `AddToPath.exe` as administrator and choose "Install Tools" 83 | - Both tools will be installed to Program Files 84 | - Creates context menu entries 85 | - Adds installation directory to system PATH 86 | - Creates `updatepath` command for refreshing PATH in current terminals 87 | 3. You can now: 88 | - Right-click folders and use the "Path" menu 89 | - Use `a2p` commands in any terminal 90 | - Use `updatepath` to refresh PATH in current terminal 91 | 92 | ## CLI Usage 93 | 94 | The `a2p` command supports the following operations: 95 | 96 | ```powershell 97 | a2p add # Add to user PATH 98 | a2p add -s # Add to system PATH (needs admin) 99 | a2p remove # Remove from user PATH 100 | a2p remove -s # Remove from system PATH (needs admin) 101 | a2p check # Check if path is in PATH 102 | a2p list # List all PATH entries 103 | ``` 104 | 105 | After modifying PATH, you can either: 106 | - Open new terminals to see the changes, or 107 | - Refresh PATH in current terminal: 108 | ``` 109 | updatepath 110 | ``` 111 | 112 | ## Uninstall 113 | 114 | To completely remove both tools, you can either: 115 | 1. Run `AddToPath.exe` and choose "Uninstall" 116 | 2. Run `AddToPath.exe --uninstall` as administrator 117 | 118 | Either way: 119 | - Removes context menu entries 120 | - Removes both tools from Program Files 121 | - Removes installation directory from PATH 122 | 123 | ## Troubleshooting 124 | 125 | The tool automatically detects common issues and will show a "Reinstall Tools" button if it finds any problems. This can fix: 126 | - Missing context menu entries 127 | - Missing PATH entries 128 | - Incorrect installation directory 129 | - Missing or outdated components 130 | 131 | For specific issues: 132 | 133 | 1. **"Access denied" when modifying system PATH** 134 | - Run the tool as administrator 135 | - For CLI, use an elevated command prompt 136 | 137 | 2. **Changes not visible in current terminal** 138 | - PATH changes only affect new terminals 139 | - Close and reopen your terminal 140 | - Use `updatepath` to refresh PATH 141 | 142 | 3. **Any other issues** 143 | - Run `AddToPath.exe` - it will detect problems and offer to fix them 144 | - Click "Reinstall Tools" if offered 145 | - The tool will repair all components and restore functionality 146 | 147 | ## Development 148 | 149 | This project is built using: 150 | - C# Windows Forms & Console Apps 151 | - .NET Framework 4.7.2 152 | - Visual Studio 2022 or later 153 | 154 | See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. 155 | 156 | ## License 157 | 158 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 159 | -------------------------------------------------------------------------------- /src/AddToPath/AddToPath.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | net472 6 | true 7 | app.manifest 8 | true 9 | Images\AddToPath.ico 10 | true 11 | 1.0.0 12 | Your Name 13 | Add to PATH Context Menu 14 | Windows utility that adds PATH management to folder context menus 15 | Copyright 2024 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | all 30 | compile; runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | all 34 | compile; runtime; build; native; contentfiles; analyzers; buildtransitive 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | $(SolutionDir)bin\$(Configuration) 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/AddToPath/Images/AddToPath.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsxdavid/AddToPath/8e9d2f4cd1ae30609bd0dc601d33e6c7bb5ee1b9/src/AddToPath/Images/AddToPath.ico -------------------------------------------------------------------------------- /src/AddToPath/Images/AddToPath.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsxdavid/AddToPath/8e9d2f4cd1ae30609bd0dc601d33e6c7bb5ee1b9/src/AddToPath/Images/AddToPath.png -------------------------------------------------------------------------------- /src/AddToPath/Images/Screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nsxdavid/AddToPath/8e9d2f4cd1ae30609bd0dc601d33e6c7bb5ee1b9/src/AddToPath/Images/Screenshot1.png -------------------------------------------------------------------------------- /src/AddToPath/MainForm.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 David Whatley 2 | // Licensed under the MIT License. See LICENSE in the project root for license information. 3 | 4 | using System; 5 | using System.Drawing; 6 | using System.Windows.Forms; 7 | using System.Diagnostics; 8 | using System.Reflection; 9 | 10 | namespace AddToPath 11 | { 12 | public class MainForm : Form 13 | { 14 | private readonly Button installButton; 15 | private readonly Button uninstallButton; 16 | private readonly Button showPathsButton; 17 | private readonly Label titleLabel; 18 | private readonly Label descriptionLabel; 19 | private readonly Panel contentPanel; 20 | private readonly TableLayoutPanel buttonPanel; 21 | 22 | public MainForm() 23 | { 24 | // Get version from assembly 25 | var version = Assembly.GetExecutingAssembly().GetName().Version; 26 | Text = "AddToPath - Windows PATH Management Tools"; 27 | Size = new Size(520, 340); // Back to original size 28 | FormBorderStyle = FormBorderStyle.FixedDialog; 29 | MaximizeBox = false; 30 | StartPosition = FormStartPosition.CenterScreen; 31 | BackColor = Color.White; 32 | Font = new Font("Segoe UI", 9F); 33 | Padding = new Padding(20); 34 | Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); 35 | 36 | // Main content panel 37 | contentPanel = new Panel 38 | { 39 | Dock = DockStyle.Fill, 40 | Padding = new Padding(0, 0, 0, 10) 41 | }; 42 | Controls.Add(contentPanel); 43 | 44 | // Title 45 | titleLabel = new Label 46 | { 47 | Text = "AddToPath", 48 | Font = new Font("Segoe UI", 16F, FontStyle.Regular), 49 | ForeColor = Color.FromArgb(0, 99, 155), 50 | AutoSize = true, 51 | Margin = new Padding(0, 0, 0, 15) 52 | }; 53 | contentPanel.Controls.Add(titleLabel); 54 | 55 | // Version label 56 | var versionLabel = new Label 57 | { 58 | Text = $"v{version.Major}.{version.Minor}.{version.Build}", 59 | Font = new Font("Segoe UI", 9F), 60 | ForeColor = Color.FromArgb(120, 120, 120), 61 | AutoSize = true, 62 | Location = new Point(contentPanel.Width - 60, titleLabel.Top + 8) // Align vertically with title 63 | }; 64 | contentPanel.Controls.Add(versionLabel); 65 | 66 | // Description text 67 | descriptionLabel = new Label 68 | { 69 | Location = new Point(0, titleLabel.Bottom + 15), 70 | Size = new Size(460, 140), // Reduce height for shorter text 71 | Font = new Font("Segoe UI", 9.75F), 72 | Text = "AddToPath provides two ways to manage your Windows PATH environment variable:\n\n" + 73 | "1. Context Menu (GUI)\n" + 74 | " • Right-click any folder, look for 'Path' menu.\n\n" + 75 | "2. Command Line (CLI)\n" + 76 | " • Use the 'a2p' command in any terminal\n", 77 | TextAlign = ContentAlignment.TopLeft 78 | }; 79 | contentPanel.Controls.Add(descriptionLabel); 80 | 81 | // Button panel 82 | buttonPanel = new TableLayoutPanel 83 | { 84 | ColumnCount = 3, 85 | Dock = DockStyle.Bottom, 86 | Height = 40, 87 | Padding = new Padding(0), 88 | ColumnStyles = { 89 | new ColumnStyle(SizeType.Percent, 33.33F), 90 | new ColumnStyle(SizeType.Percent, 33.33F), 91 | new ColumnStyle(SizeType.Percent, 33.33F) 92 | } 93 | }; 94 | 95 | Controls.Add(buttonPanel); 96 | 97 | // Install button 98 | installButton = new Button 99 | { 100 | Size = new Size(150, 32), 101 | Font = new Font("Segoe UI", 9.75F), 102 | FlatStyle = FlatStyle.Flat, 103 | Cursor = Cursors.Hand, 104 | Anchor = AnchorStyles.None, 105 | UseVisualStyleBackColor = true 106 | }; 107 | installButton.FlatAppearance.BorderColor = Color.FromArgb(0, 120, 215); 108 | installButton.Click += InstallButton_Click; 109 | buttonPanel.Controls.Add(installButton, 0, 0); 110 | 111 | // Uninstall button 112 | uninstallButton = new Button 113 | { 114 | Size = new Size(150, 32), 115 | Font = new Font("Segoe UI", 9.75F), 116 | FlatStyle = FlatStyle.Flat, 117 | Cursor = Cursors.Hand, 118 | Anchor = AnchorStyles.None, 119 | UseVisualStyleBackColor = true 120 | }; 121 | uninstallButton.FlatAppearance.BorderColor = Color.FromArgb(170, 170, 170); 122 | uninstallButton.Click += UninstallButton_Click; 123 | buttonPanel.Controls.Add(uninstallButton, 1, 0); 124 | 125 | // Show paths button 126 | showPathsButton = new Button 127 | { 128 | Size = new Size(150, 32), 129 | Font = new Font("Segoe UI", 9.75F), 130 | FlatStyle = FlatStyle.Flat, 131 | Cursor = Cursors.Hand, 132 | Anchor = AnchorStyles.None, 133 | UseVisualStyleBackColor = true 134 | }; 135 | showPathsButton.FlatAppearance.BorderColor = Color.FromArgb(0, 120, 215); 136 | showPathsButton.Click += ShowPathsButton_Click; 137 | buttonPanel.Controls.Add(showPathsButton, 2, 0); 138 | 139 | // Update button states 140 | UpdateButtonStates(); 141 | } 142 | 143 | private void UpdateButtonStates() 144 | { 145 | bool isInstalled = Program.IsInstalledInProgramFiles(); 146 | 147 | // Install button styling 148 | installButton.Text = isInstalled ? "Repair Installation" : "Install Tools"; 149 | installButton.BackColor = Color.FromArgb(0, 120, 215); 150 | installButton.ForeColor = Color.White; 151 | 152 | // Uninstall button styling 153 | uninstallButton.Text = "Uninstall"; 154 | uninstallButton.Enabled = isInstalled; 155 | uninstallButton.BackColor = uninstallButton.Enabled ? Color.White : Color.FromArgb(235, 235, 235); 156 | uninstallButton.ForeColor = uninstallButton.Enabled ? Color.FromArgb(51, 51, 51) : Color.FromArgb(160, 160, 160); 157 | uninstallButton.FlatAppearance.BorderColor = uninstallButton.Enabled ? Color.FromArgb(170, 170, 170) : Color.FromArgb(200, 200, 200); 158 | 159 | // Show paths button styling 160 | showPathsButton.Text = "Show PATHs"; 161 | showPathsButton.BackColor = Color.White; 162 | showPathsButton.ForeColor = Color.FromArgb(51, 51, 51); 163 | } 164 | 165 | private void RestartAsAdmin(string[] args = null) 166 | { 167 | ProcessStartInfo proc = new ProcessStartInfo 168 | { 169 | UseShellExecute = true, 170 | WorkingDirectory = Environment.CurrentDirectory, 171 | FileName = Application.ExecutablePath, 172 | Verb = "runas" 173 | }; 174 | 175 | if (args != null && args.Length > 0) 176 | { 177 | proc.Arguments = string.Join(" ", args); 178 | } 179 | 180 | try 181 | { 182 | Process.Start(proc); 183 | Application.Exit(); 184 | } 185 | catch (Exception) 186 | { 187 | MessageBox.Show( 188 | "Administrator rights are required to modify the system PATH and registry.", 189 | "Admin Rights Required", 190 | MessageBoxButtons.OK, 191 | MessageBoxIcon.Warning); 192 | } 193 | } 194 | 195 | private void InstallButton_Click(object sender, EventArgs e) 196 | { 197 | if (MessageBox.Show( 198 | "This will install:\n" + 199 | "1. Context menu integration for managing PATH entries\n" + 200 | "2. Command-line tool (a2p) for managing PATH from terminal\n\n" + 201 | "Administrator rights will be required to continue.", 202 | "Install AddToPath Tools", 203 | MessageBoxButtons.OKCancel, 204 | MessageBoxIcon.Information) == DialogResult.OK) 205 | { 206 | if (Program.InstallContextMenu()) 207 | { 208 | UpdateButtonStates(); 209 | } 210 | } 211 | } 212 | 213 | private void UninstallButton_Click(object sender, EventArgs e) 214 | { 215 | if (MessageBox.Show( 216 | "This will remove:\n" + 217 | "1. Context menu integration for managing PATH entries\n" + 218 | "2. Command-line tool (a2p) from system PATH\n\n" + 219 | "Administrator rights will be required to continue.", 220 | "Uninstall AddToPath Tools", 221 | MessageBoxButtons.OKCancel, 222 | MessageBoxIcon.Warning) == DialogResult.OK) 223 | { 224 | if (!Program.IsRunningAsAdmin()) 225 | { 226 | RestartAsAdmin(new[] { "--uninstall" }); 227 | return; 228 | } 229 | Program.UninstallContextMenu(); 230 | UpdateButtonStates(); 231 | } 232 | } 233 | 234 | private void ShowPathsButton_Click(object sender, EventArgs e) 235 | { 236 | try 237 | { 238 | PathsDialog.ShowPathsDialog(); 239 | } 240 | catch (Exception ex) 241 | { 242 | Logger.Log(LogLevel.Error, "MainForm", "Error showing paths dialog", ex); 243 | MessageBox.Show(this, "Failed to open paths window: " + ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); 244 | } 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/AddToPath/PathsDialog.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 David Whatley 2 | // Licensed under the MIT License. See LICENSE in the project root for license information. 3 | 4 | using System; 5 | using System.Drawing; 6 | using System.Windows.Forms; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text; 10 | using Microsoft.Win32; 11 | using System.Diagnostics; 12 | using System.Runtime.InteropServices; 13 | 14 | namespace AddToPath 15 | { 16 | /// 17 | /// Dialog for displaying and managing PATH environment variables. 18 | /// Implements window position/size persistence and handles environment change notifications. 19 | /// Only one instance of this dialog can exist across all processes. 20 | /// 21 | public partial class PathsDialog : Form 22 | { 23 | private readonly RichTextBox pathsTextBox; 24 | private readonly bool showUser; 25 | private readonly bool showSystem; 26 | private readonly Panel headerPanel; 27 | private readonly Label titleLabel; 28 | 29 | // Constants for window identification and registry storage 30 | private const string WINDOW_TITLE = "AddToPath - View PATH Variables"; 31 | private const string REG_KEY_PATH = @"Software\AddToPath"; 32 | 33 | // Registry value names for storing window position and state 34 | private const string REG_VALUE_WINDOW_LEFT = "PathsDialogLeft"; 35 | private const string REG_VALUE_WINDOW_TOP = "PathsDialogTop"; 36 | private const string REG_VALUE_WINDOW_WIDTH = "PathsDialogWidth"; 37 | private const string REG_VALUE_WINDOW_HEIGHT = "PathsDialogHeight"; 38 | private const string REG_VALUE_WINDOW_STATE = "PathsDialogState"; 39 | 40 | /// 41 | /// Factory method to show the paths dialog. Ensures only one instance exists across processes. 42 | /// If an existing window is found, it will be activated and its content refreshed. 43 | /// 44 | /// Whether to show user PATH entries 45 | /// Whether to show system PATH entries 46 | /// True if a new window was created, false if existing window was activated 47 | public static bool ShowPathsDialog(bool showUser = true, bool showSystem = true) 48 | { 49 | // Check for existing window first 50 | var existingWindow = FindExistingPathsWindow(); 51 | if (existingWindow != IntPtr.Zero) 52 | { 53 | // Restore window if minimized 54 | NativeMethods.ShowWindow(existingWindow, NativeMethods.SW_RESTORE); 55 | // Bring to front 56 | NativeMethods.SetForegroundWindow(existingWindow); 57 | // Send refresh message 58 | NativeMethods.SendMessage(existingWindow, NativeMethods.WM_REFRESH_PATHS, IntPtr.Zero, IntPtr.Zero); 59 | return false; 60 | } 61 | 62 | // Create and show new dialog 63 | using (var dialog = new PathsDialog(showUser, showSystem)) 64 | { 65 | dialog.ShowDialog(); 66 | } 67 | return true; 68 | } 69 | 70 | /// 71 | /// Searches for an existing instance of the paths dialog across all processes. 72 | /// 73 | /// Window handle if found, IntPtr.Zero if not found 74 | internal static IntPtr FindExistingPathsWindow() 75 | { 76 | // Find existing window with the same title 77 | foreach (Process proc in Process.GetProcesses()) 78 | { 79 | if (proc.MainWindowTitle == WINDOW_TITLE) 80 | { 81 | var hwnd = proc.MainWindowHandle; 82 | // Restore window if minimized 83 | NativeMethods.ShowWindow(hwnd, NativeMethods.SW_RESTORE); 84 | // Bring to front 85 | NativeMethods.SetForegroundWindow(hwnd); 86 | // Send refresh message 87 | NativeMethods.SendMessage(hwnd, NativeMethods.WM_REFRESH_PATHS, IntPtr.Zero, IntPtr.Zero); 88 | return hwnd; 89 | } 90 | } 91 | return IntPtr.Zero; 92 | } 93 | 94 | private PathsDialog(bool showUser = true, bool showSystem = true) 95 | { 96 | try 97 | { 98 | this.showUser = showUser; 99 | this.showSystem = showSystem; 100 | 101 | // Form settings 102 | Text = WINDOW_TITLE; 103 | Size = new Size(800, 600); 104 | StartPosition = FormStartPosition.CenterScreen; // This will be overridden if we have saved settings 105 | MinimizeBox = true; 106 | MaximizeBox = true; 107 | FormBorderStyle = FormBorderStyle.Sizable; 108 | BackColor = Color.White; 109 | Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); 110 | 111 | // Load any saved window settings 112 | LoadWindowSettings(); 113 | 114 | // Main container 115 | var mainContainer = new TableLayoutPanel 116 | { 117 | Dock = DockStyle.Fill, 118 | RowCount = 2, 119 | ColumnCount = 1, 120 | Margin = new Padding(0), 121 | Padding = new Padding(0) 122 | }; 123 | mainContainer.RowStyles.Add(new RowStyle(SizeType.Absolute, 60)); 124 | mainContainer.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); 125 | Controls.Add(mainContainer); 126 | 127 | // Header panel 128 | headerPanel = new Panel 129 | { 130 | Dock = DockStyle.Fill, 131 | BackColor = Color.FromArgb(0, 120, 212), 132 | Margin = new Padding(0) 133 | }; 134 | 135 | titleLabel = new Label 136 | { 137 | Text = showUser && showSystem ? "All PATHs" : 138 | showUser ? "User PATH" : "System PATH", 139 | ForeColor = Color.White, 140 | Font = new Font("Segoe UI", 16, FontStyle.Regular), 141 | AutoSize = true, 142 | Location = new Point(20, 15) 143 | }; 144 | 145 | headerPanel.Controls.Add(titleLabel); 146 | mainContainer.Controls.Add(headerPanel, 0, 0); 147 | 148 | // Main content 149 | var contentPanel = new Panel 150 | { 151 | Dock = DockStyle.Fill, 152 | Padding = new Padding(20), 153 | Margin = new Padding(0) 154 | }; 155 | 156 | pathsTextBox = new RichTextBox 157 | { 158 | Dock = DockStyle.Fill, 159 | ReadOnly = true, 160 | Font = new Font("Cascadia Code", 10F), 161 | BackColor = Color.White, 162 | ForeColor = Color.Black, 163 | BorderStyle = BorderStyle.None, 164 | WordWrap = false, 165 | Margin = new Padding(0) 166 | }; 167 | 168 | // Add a subtle border to the text box 169 | var borderPanel = new Panel 170 | { 171 | Dock = DockStyle.Fill, 172 | Padding = new Padding(1), 173 | BackColor = Color.FromArgb(200, 200, 200), 174 | Margin = new Padding(0) 175 | }; 176 | borderPanel.Controls.Add(pathsTextBox); 177 | contentPanel.Controls.Add(borderPanel); 178 | mainContainer.Controls.Add(contentPanel, 0, 1); 179 | 180 | LoadPaths(); 181 | } 182 | catch (Exception ex) 183 | { 184 | Logger.Log(LogLevel.Error, "PathsDialog", "Error in PathsDialog constructor", ex); 185 | throw; 186 | } 187 | } 188 | 189 | /// 190 | /// Handles window messages including: 191 | /// - WM_REFRESH_PATHS: Custom message to refresh path contents 192 | /// - WM_SETTINGCHANGE: System message for environment variable changes 193 | /// 194 | protected override void WndProc(ref Message m) 195 | { 196 | if (m.Msg == NativeMethods.WM_REFRESH_PATHS) 197 | { 198 | LoadPaths(); 199 | m.Result = IntPtr.Zero; 200 | return; 201 | } 202 | else if (m.Msg == NativeMethods.WM_SETTINGCHANGE) 203 | { 204 | // Check if this is an Environment change 205 | string param = Marshal.PtrToStringAuto(m.LParam); 206 | if (param == "Environment") 207 | { 208 | Logger.Log(LogLevel.Info, "PathsDialog", "Received environment change notification, refreshing paths"); 209 | LoadPaths(); 210 | } 211 | } 212 | base.WndProc(ref m); 213 | } 214 | 215 | /// 216 | /// Saves the current window position, size, and state to the registry. 217 | /// This allows the window to reopen in the same position next time. 218 | /// 219 | private void SaveWindowSettings() 220 | { 221 | try 222 | { 223 | using (var key = Registry.CurrentUser.CreateSubKey(REG_KEY_PATH)) 224 | { 225 | if (WindowState == FormWindowState.Normal) 226 | { 227 | key.SetValue(REG_VALUE_WINDOW_LEFT, Left, RegistryValueKind.DWord); 228 | key.SetValue(REG_VALUE_WINDOW_TOP, Top, RegistryValueKind.DWord); 229 | key.SetValue(REG_VALUE_WINDOW_WIDTH, Width, RegistryValueKind.DWord); 230 | key.SetValue(REG_VALUE_WINDOW_HEIGHT, Height, RegistryValueKind.DWord); 231 | } 232 | key.SetValue(REG_VALUE_WINDOW_STATE, (int)WindowState, RegistryValueKind.DWord); 233 | } 234 | } 235 | catch (Exception ex) 236 | { 237 | Logger.Log(LogLevel.Warning, "PathsDialog", "Failed to save window settings", ex); 238 | } 239 | } 240 | 241 | /// 242 | /// Loads and applies previously saved window position, size, and state from the registry. 243 | /// Ensures the window remains visible on screen even if the saved position would put it off-screen. 244 | /// 245 | private void LoadWindowSettings() 246 | { 247 | try 248 | { 249 | using (var key = Registry.CurrentUser.OpenSubKey(REG_KEY_PATH)) 250 | { 251 | if (key != null) 252 | { 253 | // Load window state first 254 | var state = key.GetValue(REG_VALUE_WINDOW_STATE); 255 | if (state != null) 256 | { 257 | WindowState = (FormWindowState)((int)state); 258 | } 259 | 260 | // Only restore position/size if we have all values and window isn't maximized 261 | if (WindowState != FormWindowState.Maximized) 262 | { 263 | var left = key.GetValue(REG_VALUE_WINDOW_LEFT); 264 | var top = key.GetValue(REG_VALUE_WINDOW_TOP); 265 | var width = key.GetValue(REG_VALUE_WINDOW_WIDTH); 266 | var height = key.GetValue(REG_VALUE_WINDOW_HEIGHT); 267 | 268 | if (left != null && top != null && width != null && height != null) 269 | { 270 | // Ensure the window will be visible on screen 271 | var screen = Screen.FromPoint(new Point((int)left, (int)top)); 272 | var workingArea = screen.WorkingArea; 273 | 274 | Left = Math.Max(workingArea.Left, Math.Min((int)left, workingArea.Right - (int)width)); 275 | Top = Math.Max(workingArea.Top, Math.Min((int)top, workingArea.Bottom - (int)height)); 276 | Width = (int)width; 277 | Height = (int)height; 278 | StartPosition = FormStartPosition.Manual; 279 | } 280 | } 281 | } 282 | } 283 | } 284 | catch (Exception ex) 285 | { 286 | Logger.Log(LogLevel.Warning, "PathsDialog", "Failed to load window settings", ex); 287 | } 288 | } 289 | 290 | private void LoadPaths() 291 | { 292 | try 293 | { 294 | var sb = new StringBuilder(); 295 | 296 | if (showUser) 297 | { 298 | var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? ""; 299 | sb.AppendLine("User PATH:"); 300 | sb.AppendLine("----------"); 301 | foreach (var path in userPath.Split(';').Where(p => !string.IsNullOrWhiteSpace(p))) 302 | { 303 | sb.AppendLine(path); 304 | } 305 | sb.AppendLine(); 306 | } 307 | 308 | if (showSystem) 309 | { 310 | var systemPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? ""; 311 | sb.AppendLine("System PATH:"); 312 | sb.AppendLine("------------"); 313 | foreach (var path in systemPath.Split(';').Where(p => !string.IsNullOrWhiteSpace(p))) 314 | { 315 | sb.AppendLine(path); 316 | } 317 | } 318 | 319 | pathsTextBox.Text = sb.ToString(); 320 | } 321 | catch (Exception ex) 322 | { 323 | Logger.Log(LogLevel.Error, "PathsDialog", "Error in LoadPaths", ex); 324 | throw; 325 | } 326 | } 327 | 328 | protected override void OnFormClosing(FormClosingEventArgs e) 329 | { 330 | SaveWindowSettings(); 331 | base.OnFormClosing(e); 332 | } 333 | 334 | protected override void OnSizeChanged(EventArgs e) 335 | { 336 | base.OnSizeChanged(e); 337 | if (pathsTextBox != null) 338 | { 339 | pathsTextBox.SelectionStart = 0; 340 | pathsTextBox.SelectionLength = 0; 341 | } 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/AddToPath/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 David Whatley 2 | // Licensed under the MIT License. See LICENSE in the project root for license information. 3 | 4 | using System; 5 | using System.Windows.Forms; 6 | using Microsoft.Win32; 7 | using System.Diagnostics; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Collections.Generic; 11 | using System.Security.Principal; 12 | using System.Text; 13 | using System.Management; 14 | using System.Runtime.InteropServices; 15 | using System.Drawing; 16 | using System.ComponentModel; 17 | 18 | namespace AddToPath 19 | { 20 | internal static class NativeMethods 21 | { 22 | public const int WM_SETICON = 0x0080; 23 | public const int HWND_BROADCAST = 0xFFFF; 24 | public const int WM_SETTINGCHANGE = 0x001A; 25 | public const uint SMTO_ABORTIFHUNG = 0x0002; 26 | public const int SW_RESTORE = 9; 27 | public const int WM_APP = 0x8000; 28 | public const int WM_REFRESH_PATHS = WM_APP + 1; 29 | 30 | [DllImport("user32.dll", CharSet = CharSet.Auto)] 31 | public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); 32 | 33 | [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] 34 | public static extern IntPtr SendMessageTimeout( 35 | IntPtr hWnd, 36 | uint Msg, 37 | UIntPtr wParam, 38 | string lParam, 39 | uint fuFlags, 40 | uint uTimeout, 41 | out UIntPtr lpdwResult); 42 | 43 | [DllImport("user32.dll")] 44 | public static extern bool SetForegroundWindow(IntPtr hWnd); 45 | 46 | [DllImport("user32.dll")] 47 | public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); 48 | } 49 | 50 | public enum LogLevel 51 | { 52 | Error, 53 | Warning, 54 | Info, 55 | Debug 56 | } 57 | 58 | internal static class Logger 59 | { 60 | private static readonly string LogDirectory = Path.Combine( 61 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 62 | "AddToPath", "Logs"); 63 | 64 | private const int DAYS_TO_KEEP_LOGS = 7; 65 | 66 | static Logger() 67 | { 68 | try 69 | { 70 | if (!Directory.Exists(LogDirectory)) 71 | { 72 | Directory.CreateDirectory(LogDirectory); 73 | } 74 | CleanupOldLogs(); 75 | } 76 | catch 77 | { 78 | // Ignore initialization errors 79 | } 80 | } 81 | 82 | public static void Log(LogLevel level, string category, string message, Exception ex = null) 83 | { 84 | try 85 | { 86 | var logFile = Path.Combine(LogDirectory, $"AddToPath_{DateTime.Now:yyyyMMdd}.log"); 87 | var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); 88 | var logMessage = $"{timestamp}|{level}|{category}|{message}"; 89 | 90 | if (ex != null) 91 | { 92 | logMessage += $"\nException: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"; 93 | } 94 | 95 | logMessage += "\n"; 96 | 97 | File.AppendAllText(logFile, logMessage); 98 | } 99 | catch 100 | { 101 | // Ignore logging errors 102 | } 103 | } 104 | 105 | private static void CleanupOldLogs() 106 | { 107 | try 108 | { 109 | var cutoff = DateTime.Now.AddDays(-DAYS_TO_KEEP_LOGS); 110 | var oldLogs = Directory.GetFiles(LogDirectory, "AddToPath_*.log") 111 | .Select(f => new FileInfo(f)) 112 | .Where(f => f.LastWriteTime < cutoff); 113 | 114 | foreach (var log in oldLogs) 115 | { 116 | try 117 | { 118 | log.Delete(); 119 | } 120 | catch 121 | { 122 | // Ignore individual file deletion errors 123 | } 124 | } 125 | } 126 | catch 127 | { 128 | // Ignore cleanup errors 129 | } 130 | } 131 | } 132 | 133 | public static class ProcessExtensions 134 | { 135 | public static Process Parent(this Process process) 136 | { 137 | try 138 | { 139 | using (var query = new ManagementObjectSearcher( 140 | $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId = {process.Id}")) 141 | { 142 | foreach (var mo in query.Get()) 143 | { 144 | var parentId = (uint)mo["ParentProcessId"]; 145 | return Process.GetProcessById((int)parentId); 146 | } 147 | } 148 | } 149 | catch 150 | { 151 | // Ignore any errors, just return null 152 | } 153 | return null; 154 | } 155 | } 156 | 157 | public class Program 158 | { 159 | private const string AppName = "Add to PATH"; 160 | private const string MenuName = "Path"; 161 | private static readonly string InstallDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "AddToPath"); 162 | private static string ExePath => Path.Combine(InstallDir, "AddToPath.exe"); 163 | 164 | public static void LogMessage(string message, LogLevel level = LogLevel.Info, string category = "General", Exception ex = null) 165 | { 166 | Logger.Log(level, category, message, ex); 167 | } 168 | 169 | [STAThread] 170 | private static void Main(string[] args) 171 | { 172 | Application.EnableVisualStyles(); 173 | Application.SetCompatibleTextRenderingDefault(false); 174 | 175 | if (args.Length == 0) 176 | { 177 | Application.Run(new MainForm()); 178 | return; 179 | } 180 | 181 | string cmd = args[0].ToLower(); 182 | LogMessage($"Command received: {cmd} with {args.Length} arguments", LogLevel.Info, "Program"); 183 | 184 | // If we have multiple arguments and it's a path command, join them 185 | string path = null; 186 | if (args.Length > 1 && (cmd == "--addtosystempath" || cmd == "--removefromsystempath" || cmd == "--addtouserpath" || cmd == "--removefromuserpath")) 187 | { 188 | // Join all arguments after the command into a single path 189 | path = string.Join(" ", args.Skip(1)); 190 | LogMessage($"Reconstructed path: {path}", LogLevel.Info, "Program"); 191 | } 192 | else if (args.Length > 1) 193 | { 194 | path = args[1]; 195 | LogMessage($"Argument 1: {path}", LogLevel.Info, "Program"); 196 | } 197 | 198 | bool needsAdmin = cmd == "--install" || 199 | cmd == "--uninstall" || 200 | cmd == "--addtosystempath" || 201 | cmd == "--removefromsystempath"; 202 | 203 | if (needsAdmin && !IsRunningAsAdmin()) 204 | { 205 | RestartAsAdmin(args); 206 | return; 207 | } 208 | 209 | switch (cmd) 210 | { 211 | case "--uninstall": 212 | UninstallContextMenu(); 213 | return; 214 | case "--addtouserpath": 215 | if (path != null && Directory.Exists(path)) 216 | { 217 | AddToPath(path, false); 218 | } 219 | return; 220 | case "--addtosystempath": 221 | if (path != null && Directory.Exists(path)) 222 | { 223 | AddToPath(path, true); 224 | } 225 | return; 226 | case "--removefromuserpath": 227 | if (path != null && Directory.Exists(path)) 228 | { 229 | RemoveFromPath(path, false); 230 | } 231 | return; 232 | case "--removefromsystempath": 233 | if (path != null && Directory.Exists(path)) 234 | { 235 | RemoveFromPath(path, true); 236 | } 237 | return; 238 | case "--showpaths": 239 | LogMessage("Showing all paths", LogLevel.Info, "Program"); 240 | ShowPaths(true, true); 241 | return; 242 | case "--checkpath": 243 | if (path != null) 244 | { 245 | var (inUserPath, inSystemPath) = CheckPathLocation(path); 246 | string msg; 247 | if (!inUserPath && !inSystemPath) 248 | msg = $"Path {path} is not in either PATH"; 249 | else if (inUserPath && inSystemPath) 250 | msg = $"Path {path} is in both user and system PATH"; 251 | else if (inUserPath) 252 | msg = $"Path {path} is in user PATH"; 253 | else 254 | msg = $"Path {path} is in system PATH"; 255 | LogMessage(msg, LogLevel.Info, "Program"); 256 | MessageBox.Show(msg, "Path Status", MessageBoxButtons.OK, MessageBoxIcon.Information); 257 | } 258 | return; 259 | case "--install": 260 | if (InstallContextMenu()) 261 | { 262 | MessageBox.Show( 263 | "AddToPath GUI and CLI (a2p) tools installed successfully!\n" + 264 | "You can now:\n" + 265 | "1. Use the context menu to manage PATH entries\n" + 266 | "2. Run 'a2p' from any terminal to manage PATH entries", 267 | "Installation Complete", 268 | MessageBoxButtons.OK, 269 | MessageBoxIcon.Information); 270 | } 271 | return; 272 | } 273 | 274 | if (!IsInstalledInProgramFiles()) 275 | { 276 | Application.Run(new MainForm()); 277 | return; 278 | } 279 | 280 | // If no arguments and running from Program Files, show the main form 281 | Application.Run(new MainForm()); 282 | } 283 | 284 | public static bool IsInstalledInProgramFiles() 285 | { 286 | try 287 | { 288 | // Check if executable exists in Program Files 289 | if (!File.Exists(ExePath)) 290 | { 291 | LogMessage("Executable not found in Program Files", LogLevel.Debug, "Installation"); 292 | return false; 293 | } 294 | 295 | // Check if registry keys exist 296 | using (var key = Registry.ClassesRoot.OpenSubKey(@"Directory\shell\Path")) 297 | { 298 | if (key == null) 299 | { 300 | LogMessage("Registry key not found", LogLevel.Debug, "Installation"); 301 | return false; 302 | } 303 | 304 | // Verify the key has our expected structure 305 | var subCommands = key.GetValue("SubCommands") as string; 306 | if (string.IsNullOrEmpty(subCommands) || !subCommands.Contains("AddToPath")) 307 | { 308 | LogMessage("Registry key missing expected structure", LogLevel.Debug, "Installation"); 309 | return false; 310 | } 311 | } 312 | 313 | LogMessage("Installation detected successfully", LogLevel.Debug, "Installation"); 314 | return true; 315 | } 316 | catch (Exception ex) 317 | { 318 | LogMessage("Error checking installation status", LogLevel.Error, "Installation", ex); 319 | return false; 320 | } 321 | } 322 | 323 | public static bool IsRunningAsAdmin() 324 | { 325 | using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) 326 | { 327 | WindowsPrincipal principal = new WindowsPrincipal(identity); 328 | return principal.IsInRole(WindowsBuiltInRole.Administrator); 329 | } 330 | } 331 | 332 | public static void RestartAsAdmin(string[] args = null) 333 | { 334 | ProcessStartInfo proc = new ProcessStartInfo 335 | { 336 | UseShellExecute = true, 337 | WorkingDirectory = Environment.CurrentDirectory, 338 | FileName = Application.ExecutablePath, 339 | Verb = "runas" 340 | }; 341 | 342 | if (args != null && args.Length > 0) 343 | { 344 | proc.Arguments = string.Join(" ", args); 345 | } 346 | 347 | try 348 | { 349 | Process.Start(proc); 350 | Application.Exit(); 351 | } 352 | catch (Exception ex) 353 | { 354 | LogMessage("Failed to restart as admin", LogLevel.Error, "Program", ex); 355 | MessageBox.Show( 356 | "Administrator rights are required to modify the system PATH and registry.", 357 | "Admin Rights Required", 358 | MessageBoxButtons.OK, 359 | MessageBoxIcon.Warning); 360 | } 361 | } 362 | 363 | private static string GetProcessDetails(Process proc) 364 | { 365 | try 366 | { 367 | return $"ID={proc.Id}, " + 368 | $"Path={proc.MainModule?.FileName ?? "unknown"}, " + 369 | $"Started={proc.StartTime:HH:mm:ss.fff}, " + 370 | $"Parent={proc.Parent()?.Id ?? 0}"; 371 | } 372 | catch (Exception ex) 373 | { 374 | return $"ID={proc.Id}, Error getting details: {ex.Message}"; 375 | } 376 | } 377 | 378 | private static bool IsSameApplication(string path1, string path2) 379 | { 380 | if (string.Equals(path1, path2, StringComparison.OrdinalIgnoreCase)) 381 | return true; 382 | 383 | // Consider Program Files version as the same application 384 | var programFilesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "AddToPath", "AddToPath.exe"); 385 | return (string.Equals(path1, programFilesPath, StringComparison.OrdinalIgnoreCase) || 386 | string.Equals(path2, programFilesPath, StringComparison.OrdinalIgnoreCase)); 387 | } 388 | 389 | private static bool AreOtherInstancesRunning() 390 | { 391 | var currentProcess = Process.GetCurrentProcess(); 392 | var currentPath = currentProcess.MainModule?.FileName; 393 | LogMessage($"Current process: {GetProcessDetails(currentProcess)}", LogLevel.Debug, "Program"); 394 | 395 | var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ExePath)); 396 | LogMessage($"Found {processes.Length} total processes with our name", LogLevel.Debug, "Program"); 397 | 398 | foreach (var proc in processes) 399 | { 400 | LogMessage($"Found process: {GetProcessDetails(proc)}", LogLevel.Debug, "Program"); 401 | } 402 | 403 | return processes.Any(p => p.Id != currentProcess.Id && 404 | IsSameApplication(p.MainModule?.FileName, currentPath)); 405 | } 406 | 407 | private static bool KillOtherInstances() 408 | { 409 | var currentProcess = Process.GetCurrentProcess(); 410 | var currentPath = currentProcess.MainModule?.FileName; 411 | var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ExePath)); 412 | var otherProcesses = processes 413 | .Where(p => p.Id != currentProcess.Id && 414 | IsSameApplication(p.MainModule?.FileName, currentPath)) 415 | .ToList(); 416 | 417 | if (!otherProcesses.Any()) 418 | { 419 | LogMessage("No other instances found to kill", LogLevel.Debug, "Program"); 420 | return true; 421 | } 422 | 423 | LogMessage($"Found {otherProcesses.Count} other instances to kill", LogLevel.Debug, "Program"); 424 | foreach (var proc in otherProcesses) 425 | { 426 | LogMessage($"Will attempt to kill: {GetProcessDetails(proc)}", LogLevel.Debug, "Program"); 427 | } 428 | 429 | foreach (var process in otherProcesses) 430 | { 431 | try 432 | { 433 | process.Kill(); 434 | process.WaitForExit(5000); // Wait up to 5 seconds for each process 435 | } 436 | catch (Exception ex) 437 | { 438 | LogMessage($"Failed to kill process {process.Id}", LogLevel.Error, "Program", ex); 439 | } 440 | } 441 | 442 | // Double check all processes were killed 443 | return !AreOtherInstancesRunning(); 444 | } 445 | 446 | public static bool InstallContextMenu() 447 | { 448 | if (!IsRunningAsAdmin()) 449 | { 450 | RestartAsAdmin(new[] { "--install" }); 451 | return false; 452 | } 453 | 454 | if (AreOtherInstancesRunning()) 455 | { 456 | KillOtherInstances(); 457 | } 458 | 459 | try 460 | { 461 | // Create installation directory if it doesn't exist 462 | if (!Directory.Exists(InstallDir)) 463 | { 464 | try 465 | { 466 | Directory.CreateDirectory(InstallDir); 467 | } 468 | catch (Exception ex) 469 | { 470 | LogMessage("Failed to create installation directory", LogLevel.Error, "Installation", ex); 471 | MessageBox.Show( 472 | $"Installation failed: Could not create installation directory\n{ex.Message}", 473 | "Error", 474 | MessageBoxButtons.OK, 475 | MessageBoxIcon.Error); 476 | return false; 477 | } 478 | } 479 | 480 | // Check source files exist 481 | string sourceExe = Application.ExecutablePath; 482 | string sourceDir = Path.GetDirectoryName(sourceExe); 483 | string a2pSourcePath = Path.Combine(sourceDir, "a2p.exe"); 484 | 485 | if (!File.Exists(sourceExe) || !File.Exists(a2pSourcePath)) 486 | { 487 | LogMessage("Required files missing", LogLevel.Error, "Installation"); 488 | MessageBox.Show( 489 | "Installation failed: Required files not found.\n" + 490 | "Make sure both AddToPath.exe and a2p.exe are in the same directory.", 491 | "Error", 492 | MessageBoxButtons.OK, 493 | MessageBoxIcon.Error); 494 | return false; 495 | } 496 | 497 | // Copy executables 498 | try 499 | { 500 | File.Copy(sourceExe, ExePath, overwrite: true); 501 | File.Copy(a2pSourcePath, Path.Combine(InstallDir, "a2p.exe"), overwrite: true); 502 | } 503 | catch (Exception ex) 504 | { 505 | var error = ex is UnauthorizedAccessException ? ex : 506 | ex.InnerException as UnauthorizedAccessException; 507 | 508 | if (error != null) 509 | { 510 | LogMessage($"Access denied copying files. HRESULT: 0x{error.HResult:X8}", 511 | LogLevel.Error, "Installation", ex); 512 | MessageBox.Show( 513 | $"Installation failed: Access denied copying files to Program Files\n" + 514 | $"HRESULT: 0x{error.HResult:X8}\n" + 515 | $"Error: {error.Message}\n" + 516 | $"IsAdmin: {IsRunningAsAdmin()}\n" + 517 | $"ExePath: {ExePath}\n" + 518 | $"Process: {Process.GetCurrentProcess().MainModule.FileName}", 519 | "Error", 520 | MessageBoxButtons.OK, 521 | MessageBoxIcon.Error); 522 | } 523 | else 524 | { 525 | LogMessage($"Failed to copy files. Exception: {ex.GetType().Name}, Message: {ex.Message}", 526 | LogLevel.Error, "Installation", ex); 527 | MessageBox.Show( 528 | $"Installation failed: Could not copy files to Program Files\nError: {ex.Message}", 529 | "Error", 530 | MessageBoxButtons.OK, 531 | MessageBoxIcon.Error); 532 | } 533 | return false; 534 | } 535 | 536 | // Create helper scripts 537 | try 538 | { 539 | File.WriteAllText( 540 | Path.Combine(InstallDir, "updatepath.ps1"), 541 | "$env:Path = [System.Environment]::GetEnvironmentVariable(\"Path\",\"Machine\") + \";\" + [System.Environment]::GetEnvironmentVariable(\"Path\",\"User\")"); 542 | 543 | File.WriteAllText( 544 | Path.Combine(InstallDir, "updatepath.bat"), 545 | "@echo off\nfor /f \"tokens=2*\" %%a in ('reg query \"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment\" /v Path') do set SYSPATH=%%b\nfor /f \"tokens=2*\" %%a in ('reg query \"HKCU\\Environment\" /v Path') do set USERPATH=%%b\nset PATH=%SYSPATH%;%USERPATH%"); 546 | } 547 | catch (Exception ex) 548 | { 549 | LogMessage("Failed to create helper scripts", LogLevel.Error, "Installation", ex); 550 | MessageBox.Show( 551 | $"Installation failed: Could not create helper scripts\n{ex.Message}", 552 | "Error", 553 | MessageBoxButtons.OK, 554 | MessageBoxIcon.Error); 555 | return false; 556 | } 557 | 558 | // Create registry entries 559 | try 560 | { 561 | // Create main menu entry 562 | using (var key = Registry.ClassesRoot.CreateSubKey(@"Directory\shell\Path")) 563 | { 564 | key.SetValue("", ""); // Empty default value 565 | key.SetValue("MUIVerb", "Path"); 566 | key.SetValue("Icon", $"\"{ExePath}\""); // Add icon from our executable 567 | key.SetValue("SubCommands", "1_AddToPath;2_RemoveFromPath;3_CheckPath;4_ShowPaths"); // Main menu commands 568 | } 569 | 570 | // Create Shell container for submenus 571 | Registry.ClassesRoot.CreateSubKey(@"Directory\shell\Path\Shell"); 572 | 573 | // Add to PATH submenu 574 | using (var addKey = Registry.ClassesRoot.CreateSubKey(@"Directory\shell\Path\Shell\1_AddToPath")) 575 | { 576 | addKey.SetValue("", ""); // Empty default value 577 | addKey.SetValue("MUIVerb", "Add to PATH"); 578 | addKey.SetValue("SubCommands", ""); // This tells Windows it has subcommands 579 | 580 | // Add User PATH command 581 | using (var userKey = addKey.CreateSubKey(@"Shell\User")) 582 | { 583 | userKey.SetValue("MUIVerb", "User PATH"); 584 | using (var cmdKey = userKey.CreateSubKey("command")) 585 | { 586 | cmdKey.SetValue("", $"\"{ExePath}\" --addtouserpath \"%1\""); 587 | } 588 | } 589 | 590 | // Add System PATH command 591 | using (var systemKey = addKey.CreateSubKey(@"Shell\System")) 592 | { 593 | systemKey.SetValue("MUIVerb", "System PATH"); 594 | using (var cmdKey = systemKey.CreateSubKey("command")) 595 | { 596 | cmdKey.SetValue("", $"\"{ExePath}\" --addtosystempath \"%1\""); 597 | } 598 | } 599 | } 600 | 601 | // Remove from PATH submenu 602 | using (var removeKey = Registry.ClassesRoot.CreateSubKey(@"Directory\shell\Path\Shell\2_RemoveFromPath")) 603 | { 604 | removeKey.SetValue("", ""); // Empty default value 605 | removeKey.SetValue("MUIVerb", "Remove from PATH"); 606 | removeKey.SetValue("SubCommands", ""); // This tells Windows it has subcommands 607 | 608 | // Remove User PATH command 609 | using (var userKey = removeKey.CreateSubKey(@"Shell\User")) 610 | { 611 | userKey.SetValue("MUIVerb", "User PATH"); 612 | using (var cmdKey = userKey.CreateSubKey("command")) 613 | { 614 | cmdKey.SetValue("", $"\"{ExePath}\" --removefromuserpath \"%1\""); 615 | } 616 | } 617 | 618 | // Remove System PATH command 619 | using (var systemKey = removeKey.CreateSubKey(@"Shell\System")) 620 | { 621 | systemKey.SetValue("MUIVerb", "System PATH"); 622 | using (var cmdKey = systemKey.CreateSubKey("command")) 623 | { 624 | cmdKey.SetValue("", $"\"{ExePath}\" --removefromsystempath \"%1\""); 625 | } 626 | } 627 | } 628 | 629 | // Check PATH Status command 630 | using (var checkKey = Registry.ClassesRoot.CreateSubKey(@"Directory\shell\Path\Shell\3_CheckPath")) 631 | { 632 | checkKey.SetValue("MUIVerb", "Check PATH Status"); 633 | using (var cmdKey = checkKey.CreateSubKey("command")) 634 | { 635 | cmdKey.SetValue("", $"\"{ExePath}\" --checkpath \"%1\""); 636 | } 637 | } 638 | 639 | // Show PATHs command 640 | using (var showKey = Registry.ClassesRoot.CreateSubKey(@"Directory\shell\Path\Shell\4_ShowPaths")) 641 | { 642 | showKey.SetValue("MUIVerb", "Show PATHs"); 643 | using (var cmdKey = showKey.CreateSubKey("command")) 644 | { 645 | cmdKey.SetValue("", $"\"{ExePath}\" --showpaths"); 646 | } 647 | } 648 | } 649 | catch (Exception ex) 650 | { 651 | LogMessage("Failed to create registry entries", LogLevel.Error, "Installation", ex); 652 | MessageBox.Show( 653 | $"Installation failed: Could not create context menu entries\n{ex.Message}", 654 | "Error", 655 | MessageBoxButtons.OK, 656 | MessageBoxIcon.Error); 657 | return false; 658 | } 659 | 660 | // Add to system PATH if needed 661 | try 662 | { 663 | var (inUserPath, inSystemPath) = CheckPathLocation(InstallDir); 664 | if (!inSystemPath) 665 | { 666 | AddToPath(InstallDir, true, false); 667 | } 668 | } 669 | catch (Exception ex) 670 | { 671 | LogMessage("Failed to add to system PATH", LogLevel.Error, "Installation", ex); 672 | MessageBox.Show( 673 | $"Installation failed: Could not add to system PATH\n{ex.Message}", 674 | "Error", 675 | MessageBoxButtons.OK, 676 | MessageBoxIcon.Error); 677 | return false; 678 | } 679 | 680 | return true; 681 | } 682 | catch (Exception ex) 683 | { 684 | LogMessage("Failed to install context menu", LogLevel.Error, "Registry", ex); 685 | MessageBox.Show( 686 | $"Error installing context menu: {ex.Message}", 687 | "Error", 688 | MessageBoxButtons.OK, 689 | MessageBoxIcon.Error); 690 | return false; 691 | } 692 | } 693 | 694 | public static void UninstallContextMenu() 695 | { 696 | if (!IsRunningAsAdmin()) 697 | { 698 | RestartAsAdmin(new[] { "--uninstall" }); 699 | return; 700 | } 701 | 702 | if (AreOtherInstancesRunning()) 703 | { 704 | KillOtherInstances(); 705 | } 706 | 707 | try 708 | { 709 | // Check if current directory is inside our install directory 710 | var currentDir = Directory.GetCurrentDirectory(); 711 | if (currentDir.StartsWith(InstallDir, StringComparison.OrdinalIgnoreCase)) 712 | { 713 | Directory.SetCurrentDirectory(Path.GetTempPath()); 714 | } 715 | 716 | // Remove registry entries 717 | Registry.ClassesRoot.DeleteSubKeyTree(@"Directory\shell\" + MenuName, false); 718 | Registry.ClassesRoot.DeleteSubKeyTree(@"Directory\Background\shell\" + MenuName, false); 719 | 720 | // Remove from PATH 721 | RemoveFromPath(InstallDir, true, true, true); // Silent if not found during uninstall 722 | 723 | // Create cleanup script in temp directory 724 | var processId = Process.GetCurrentProcess().Id; 725 | var cleanupScript = Path.Combine(Path.GetTempPath(), $"addtopath_cleanup_{processId}.ps1"); 726 | 727 | // Clean up any existing script first 728 | try 729 | { 730 | if (File.Exists(cleanupScript)) 731 | { 732 | File.Delete(cleanupScript); 733 | } 734 | } 735 | catch 736 | { 737 | // Ignore cleanup errors 738 | } 739 | 740 | File.WriteAllText(cleanupScript, $@" 741 | while (Get-Process -Id {processId} -ErrorAction SilentlyContinue) {{ 742 | Start-Sleep -Milliseconds 100 743 | }} 744 | Remove-Item -Path '{InstallDir}' -Recurse -Force -ErrorAction SilentlyContinue 745 | "); 746 | 747 | // Launch cleanup script detached 748 | var startInfo = new ProcessStartInfo 749 | { 750 | FileName = "powershell.exe", 751 | Arguments = $"-WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File \"{cleanupScript}\"", 752 | UseShellExecute = true, 753 | CreateNoWindow = true, 754 | WindowStyle = ProcessWindowStyle.Hidden 755 | }; 756 | Process.Start(startInfo); 757 | 758 | MessageBox.Show("AddToPath has been uninstalled", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); 759 | Environment.Exit(0); // Exit to let the cleanup script do its work 760 | } 761 | catch (Exception ex) 762 | { 763 | LogMessage("Failed to uninstall", LogLevel.Error, "Uninstall", ex); 764 | MessageBox.Show($"Failed to uninstall: {ex.Message}", AppName, MessageBoxButtons.OK, MessageBoxIcon.Error); 765 | } 766 | } 767 | 768 | private static void BroadcastEnvironmentChange() 769 | { 770 | try 771 | { 772 | UIntPtr result; 773 | NativeMethods.SendMessageTimeout( 774 | (IntPtr)NativeMethods.HWND_BROADCAST, 775 | NativeMethods.WM_SETTINGCHANGE, 776 | UIntPtr.Zero, 777 | "Environment", 778 | NativeMethods.SMTO_ABORTIFHUNG, 779 | 1000, 780 | out result); 781 | LogMessage("Broadcast environment change notification", LogLevel.Debug, "Environment"); 782 | } 783 | catch (Exception ex) 784 | { 785 | LogMessage("Failed to broadcast environment change", LogLevel.Warning, "Environment", ex); 786 | } 787 | } 788 | 789 | public static void AddToPath(string path, bool isSystem, bool showUI = true) 790 | { 791 | try 792 | { 793 | if (string.IsNullOrWhiteSpace(path)) 794 | { 795 | if (showUI) 796 | MessageBox.Show("Please enter a valid path.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); 797 | throw new ArgumentException("Path cannot be empty"); 798 | } 799 | 800 | if (!Directory.Exists(path)) 801 | { 802 | if (showUI) 803 | MessageBox.Show("Directory does not exist.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); 804 | throw new ArgumentException("Directory does not exist"); 805 | } 806 | 807 | EnvironmentVariableTarget target = isSystem ? EnvironmentVariableTarget.Machine : EnvironmentVariableTarget.User; 808 | var envPath = Environment.GetEnvironmentVariable("PATH", target) ?? ""; 809 | var paths = envPath.Split(';').Select(p => p.Trim()).Where(p => !string.IsNullOrWhiteSpace(p)).ToList(); 810 | 811 | if (paths.Contains(path, StringComparer.OrdinalIgnoreCase)) 812 | { 813 | var msg = $"Path {path} already exists in {(isSystem ? "system" : "user")} PATH"; 814 | if (showUI) 815 | MessageBox.Show(msg, "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); 816 | throw new InvalidOperationException(msg); 817 | } 818 | 819 | paths.Add(path); 820 | Environment.SetEnvironmentVariable("PATH", string.Join(";", paths), target); 821 | 822 | if (showUI) 823 | MessageBox.Show( 824 | $"Added {path} to {(isSystem ? "system" : "user")} PATH.\n\n" + 825 | "Run 'updatepath' to refresh PATH in any current terminal.\n", 826 | "Success", 827 | MessageBoxButtons.OK, 828 | MessageBoxIcon.Information); 829 | else 830 | { 831 | Console.WriteLine($"Added {path} to {(isSystem ? "system" : "user")} PATH"); 832 | Console.WriteLine("Run 'updatepath' to refresh PATH in current terminal"); 833 | } 834 | 835 | LogMessage($"Added {path} to {(isSystem ? "system" : "user")} PATH", LogLevel.Info, "PathOperation"); 836 | BroadcastEnvironmentChange(); 837 | } 838 | catch (Exception ex) 839 | { 840 | LogMessage($"Error adding path: {ex.Message}", LogLevel.Error, "PathOperation", ex); 841 | throw; 842 | } 843 | } 844 | 845 | public static bool RemoveFromPath(string path, bool isSystem, bool showUI = true, bool silentIfNotFound = false) 846 | { 847 | EnvironmentVariableTarget target = isSystem ? EnvironmentVariableTarget.Machine : EnvironmentVariableTarget.User; 848 | var envPath = Environment.GetEnvironmentVariable("PATH", target) ?? ""; 849 | var paths = envPath.Split(';').Select(p => p.Trim()).Where(p => !string.IsNullOrWhiteSpace(p)).ToList(); 850 | 851 | if (!paths.Contains(path, StringComparer.OrdinalIgnoreCase)) 852 | { 853 | var msg = $"Path {path} not found in {(isSystem ? "system" : "user")} PATH"; 854 | if (showUI && !silentIfNotFound) 855 | MessageBox.Show(msg, "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); 856 | if (!silentIfNotFound) 857 | throw new InvalidOperationException(msg); 858 | return false; 859 | } 860 | 861 | paths.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); 862 | Environment.SetEnvironmentVariable("PATH", string.Join(";", paths), target); 863 | 864 | if (showUI) 865 | MessageBox.Show( 866 | $"Removed {path} from {(isSystem ? "system" : "user")} PATH.\n\n" + 867 | "Run 'updatepath' to refresh PATH in any current terminal.\n", 868 | "Success", 869 | MessageBoxButtons.OK, 870 | MessageBoxIcon.Information); 871 | else 872 | { 873 | Console.WriteLine($"Removed {path} from {(isSystem ? "system" : "user")} PATH"); 874 | Console.WriteLine("Run 'updatepath' to refresh PATH in current terminal"); 875 | } 876 | 877 | LogMessage($"Removed {path} from {(isSystem ? "system" : "user")} PATH", LogLevel.Info, "PathOperation"); 878 | 879 | BroadcastEnvironmentChange(); 880 | return true; 881 | } 882 | 883 | public static void AddToUserPath(string path) 884 | { 885 | AddToPath(path, false, false); 886 | } 887 | 888 | public static void AddToSystemPath(string path) 889 | { 890 | AddToPath(path, true, false); 891 | } 892 | 893 | public static void RemoveFromUserPath(string path) 894 | { 895 | RemoveFromPath(path, false, false); 896 | } 897 | 898 | public static void RemoveFromSystemPath(string path) 899 | { 900 | RemoveFromPath(path, true, false); 901 | } 902 | 903 | public static void ShowPaths(bool showUser = true, bool showSystem = true) 904 | { 905 | try 906 | { 907 | LogMessage($"ShowPaths called with showUser={showUser}, showSystem={showSystem}", LogLevel.Info, "Program"); 908 | Application.EnableVisualStyles(); 909 | Application.SetCompatibleTextRenderingDefault(false); 910 | 911 | if (!PathsDialog.ShowPathsDialog(showUser, showSystem)) 912 | { 913 | LogMessage("Existing paths window found and activated", LogLevel.Info, "Program"); 914 | } 915 | } 916 | catch (Exception ex) 917 | { 918 | LogMessage("Failed to show paths", LogLevel.Error, "Program", ex); 919 | MessageBox.Show($"Error showing paths: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); 920 | } 921 | } 922 | 923 | public static string[] GetUserPaths() 924 | { 925 | return Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User)?.Split(';') ?? new string[0]; 926 | } 927 | 928 | public static string[] GetSystemPaths() 929 | { 930 | return Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine)?.Split(';') ?? new string[0]; 931 | } 932 | 933 | public static (bool InUserPath, bool InSystemPath) CheckPathLocation(string path) 934 | { 935 | if (string.IsNullOrWhiteSpace(path)) 936 | return (false, false); 937 | 938 | // Normalize the path for comparison 939 | path = Path.GetFullPath(path).TrimEnd('\\'); 940 | 941 | var userPaths = GetUserPaths() 942 | .Where(p => !string.IsNullOrWhiteSpace(p)) 943 | .Select(p => Path.GetFullPath(p).TrimEnd('\\')); 944 | 945 | var systemPaths = GetSystemPaths() 946 | .Where(p => !string.IsNullOrWhiteSpace(p)) 947 | .Select(p => Path.GetFullPath(p).TrimEnd('\\')); 948 | 949 | return ( 950 | userPaths.Contains(path, StringComparer.OrdinalIgnoreCase), 951 | systemPaths.Contains(path, StringComparer.OrdinalIgnoreCase) 952 | ); 953 | } 954 | } 955 | } -------------------------------------------------------------------------------- /src/AddToPath/Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | namespace AddToPath.Properties { 2 | using System; 3 | 4 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 5 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 6 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 7 | internal class Resources { 8 | 9 | private static global::System.Resources.ResourceManager resourceMan; 10 | 11 | private static global::System.Globalization.CultureInfo resourceCulture; 12 | 13 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 14 | internal Resources() { 15 | } 16 | 17 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 18 | internal static global::System.Resources.ResourceManager ResourceManager { 19 | get { 20 | if (object.ReferenceEquals(resourceMan, null)) { 21 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("AddToPath.Properties.Resources", typeof(Resources).Assembly); 22 | resourceMan = temp; 23 | } 24 | return resourceMan; 25 | } 26 | } 27 | 28 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 29 | internal static global::System.Globalization.CultureInfo Culture { 30 | get { 31 | return resourceCulture; 32 | } 33 | set { 34 | resourceCulture = value; 35 | } 36 | } 37 | 38 | internal static System.Drawing.Icon AppIcon { 39 | get { 40 | object obj = ResourceManager.GetObject("AppIcon", resourceCulture); 41 | return ((System.Drawing.Icon)(obj)); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AddToPath/Properties/Resources.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ..\Images\AddToPath.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/AddToPath/app.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/a2p/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 David Whatley 2 | // Licensed under the MIT License. See LICENSE in the project root for license information. 3 | 4 | using System; 5 | using System.Diagnostics; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Security.Principal; 9 | using System.Threading; 10 | 11 | namespace a2p 12 | { 13 | /// 14 | /// Command-line interface for AddToPath. Provides add/remove functionality for PATH environment variables 15 | /// with support for both user and system paths. System path modifications require administrator rights 16 | /// and are handled through a secure UAC elevation process. 17 | /// 18 | class Program 19 | { 20 | static int Main(string[] args) 21 | { 22 | try 23 | { 24 | // Check if we're the elevated instance by looking for the output file parameter 25 | string outputFile = null; 26 | if (args.Length > 0 && args[0].StartsWith("--output-file=")) 27 | { 28 | outputFile = args[0].Substring("--output-file=".Length); 29 | args = args.Skip(1).ToArray(); // Remove the output file argument 30 | AddToPath.Program.LogMessage("Started elevated instance with output file: " + outputFile, AddToPath.LogLevel.Info, "CLI"); 31 | } 32 | 33 | if (args.Length == 0 || args[0].ToLower() == "h" || args[0].ToLower() == "help") 34 | { 35 | ShowUsage(); 36 | return 0; 37 | } 38 | 39 | string cmd = args[0].ToLower(); 40 | if (cmd == "l" || cmd == "list") 41 | { 42 | ShowPaths(); 43 | return 0; 44 | } 45 | 46 | // Check if a path is in PATH variables 47 | if (cmd == "c" || cmd == "check") 48 | { 49 | if (args.Length < 2) 50 | { 51 | Console.WriteLine("Please specify a path to check"); 52 | ShowUsage(); 53 | return 1; 54 | } 55 | 56 | string checkPath = args[1]; 57 | try 58 | { 59 | string pathToCheck = Path.GetFullPath(checkPath); 60 | var (inUser, inSystem) = AddToPath.Program.CheckPathLocation(pathToCheck); 61 | 62 | if (!inUser && !inSystem) 63 | { 64 | Console.WriteLine($"'{pathToCheck}' is not in PATH"); 65 | return 1; 66 | } 67 | 68 | if (inUser) 69 | Console.WriteLine($"'{pathToCheck}' is in user PATH"); 70 | if (inSystem) 71 | Console.WriteLine($"'{pathToCheck}' is in system PATH"); 72 | return 0; 73 | } 74 | catch (Exception ex) 75 | { 76 | Console.WriteLine($"Error checking path: {ex.Message}"); 77 | return 1; 78 | } 79 | } 80 | 81 | if (args.Length < 3) 82 | { 83 | ShowUsage(); 84 | return 1; 85 | } 86 | 87 | string scope = args[1].ToLower(); 88 | bool isSystem = scope == "s" || scope == "system"; 89 | 90 | if (scope != "u" && scope != "user" && scope != "s" && scope != "system") 91 | { 92 | Console.WriteLine("Invalid scope. Use:"); 93 | Console.WriteLine(" u or user: for user PATH"); 94 | Console.WriteLine(" s or system: for system PATH"); 95 | return 1; 96 | } 97 | 98 | string inputPath = args[2]; 99 | string fullPath = Path.GetFullPath(inputPath); 100 | 101 | // If we're attempting to modify the system PATH, we'll need admin rights 102 | if (isSystem && !AddToPath.Program.IsRunningAsAdmin()) 103 | { 104 | // The UAC elevation process works by spawning a new elevated instance of this program. 105 | // To maintain a seamless user experience where all output appears in the original console: 106 | // 1. The original instance creates a temporary file for communication 107 | // 2. The elevated instance is started with this file path and the original arguments 108 | // 3. The elevated instance redirects its console output to this file 109 | // 4. The original instance monitors the file and displays its contents 110 | // This allows us to show output from the elevated process in the original console window. 111 | 112 | // Create a temporary file that will be used to capture output from the elevated process. 113 | // This file is automatically cleaned up after use or in case of errors. 114 | string tempFile = Path.Combine(Path.GetTempPath(), $"a2p-{Guid.NewGuid()}.tmp"); 115 | AddToPath.Program.LogMessage($"Initiating UAC elevation with temp file: {tempFile}", AddToPath.LogLevel.Info, "CLI"); 116 | 117 | try 118 | { 119 | Console.WriteLine("Administrator rights are required. Waiting for UAC prompt..."); 120 | 121 | // Start a new elevated instance of ourselves. 122 | // UseShellExecute and Verb="runas" trigger the UAC prompt. 123 | // WindowStyle=Hidden prevents the elevated console window from flashing. 124 | var proc = new ProcessStartInfo 125 | { 126 | UseShellExecute = true, 127 | WorkingDirectory = Environment.CurrentDirectory, 128 | FileName = Process.GetCurrentProcess().MainModule.FileName, 129 | Verb = "runas", 130 | Arguments = $"--output-file={tempFile} {string.Join(" ", args)}", 131 | WindowStyle = ProcessWindowStyle.Hidden 132 | }; 133 | 134 | AddToPath.Program.LogMessage("Starting elevated process", AddToPath.LogLevel.Info, "CLI"); 135 | var elevatedProcess = Process.Start(proc); 136 | 137 | // Monitor the temp file while the elevated process runs. 138 | // When content appears, immediately display it and delete the file 139 | // to make room for more output. 140 | while (!elevatedProcess.HasExited) 141 | { 142 | Thread.Sleep(100); 143 | if (File.Exists(tempFile)) 144 | { 145 | try 146 | { 147 | string[] lines = File.ReadAllLines(tempFile); 148 | File.Delete(tempFile); 149 | foreach (var line in lines) 150 | { 151 | Console.WriteLine(line); 152 | } 153 | } 154 | catch (Exception ex) 155 | { 156 | AddToPath.Program.LogMessage("Error reading temp file", AddToPath.LogLevel.Error, "CLI", ex); 157 | } 158 | } 159 | } 160 | 161 | // One final check for output after process exits 162 | if (File.Exists(tempFile)) 163 | { 164 | try 165 | { 166 | string[] lines = File.ReadAllLines(tempFile); 167 | File.Delete(tempFile); 168 | foreach (var line in lines) 169 | { 170 | Console.WriteLine(line); 171 | } 172 | } 173 | catch (Exception ex) 174 | { 175 | AddToPath.Program.LogMessage("Error reading final temp file", AddToPath.LogLevel.Error, "CLI", ex); 176 | } 177 | } 178 | 179 | AddToPath.Program.LogMessage($"Elevated process completed with exit code: {elevatedProcess.ExitCode}", AddToPath.LogLevel.Info, "CLI"); 180 | return elevatedProcess.ExitCode; 181 | } 182 | catch (System.ComponentModel.Win32Exception ex) when ((uint)ex.HResult == 0x80004005) // Access Denied (UAC canceled) 183 | { 184 | AddToPath.Program.LogMessage("UAC elevation was denied by user", AddToPath.LogLevel.Warning, "CLI", ex); 185 | Console.WriteLine("Operation canceled - administrator rights were denied."); 186 | Console.WriteLine("To modify the system PATH, you must run this command as administrator."); 187 | if (File.Exists(tempFile)) File.Delete(tempFile); 188 | return 1; 189 | } 190 | catch (Exception ex) 191 | { 192 | AddToPath.Program.LogMessage("Failed to start elevated process", AddToPath.LogLevel.Error, "CLI", ex); 193 | Console.WriteLine($"Failed to restart as administrator: {ex.Message}"); 194 | Console.WriteLine("Try running this command as administrator."); 195 | if (File.Exists(tempFile)) File.Delete(tempFile); 196 | return 1; 197 | } 198 | } 199 | 200 | // If we're the elevated instance, redirect console output to the file 201 | // so the original process can display it. This ensures all output, 202 | // including exceptions, appears in the original console window. 203 | if (outputFile != null) 204 | { 205 | var originalOut = Console.Out; 206 | var originalError = Console.Error; 207 | try 208 | { 209 | using (var writer = new StreamWriter(outputFile, false)) 210 | { 211 | Console.SetOut(writer); 212 | Console.SetError(writer); 213 | try 214 | { 215 | int result = ExecuteCommand(cmd, isSystem, fullPath); 216 | writer.Flush(); 217 | return result; 218 | } 219 | catch (InvalidOperationException ex) 220 | { 221 | // InvalidOperationException is used for normal status messages 222 | // like "path not found". These should be displayed without 223 | // error formatting or stack traces. 224 | AddToPath.Program.LogMessage($"Operation status: {ex.Message}", AddToPath.LogLevel.Info, "CLI"); 225 | Console.WriteLine(ex.Message); 226 | writer.Flush(); 227 | return 1; 228 | } 229 | catch (Exception ex) 230 | { 231 | // Unexpected errors should show full details to help with debugging 232 | AddToPath.Program.LogMessage("Unexpected error in elevated process", AddToPath.LogLevel.Error, "CLI", ex); 233 | Console.WriteLine($"An unexpected error occurred:"); 234 | Console.WriteLine(ex.Message); 235 | if (ex.InnerException != null) 236 | { 237 | Console.WriteLine($"Details: {ex.InnerException.Message}"); 238 | } 239 | Console.WriteLine(ex.StackTrace); 240 | writer.Flush(); 241 | return 1; 242 | } 243 | } 244 | } 245 | finally 246 | { 247 | // Always restore the console output streams 248 | Console.SetOut(originalOut); 249 | Console.SetError(originalError); 250 | } 251 | } 252 | 253 | return ExecuteCommand(cmd, isSystem, fullPath); 254 | } 255 | catch (Exception ex) 256 | { 257 | Console.WriteLine(ex.Message); 258 | return 1; 259 | } 260 | } 261 | 262 | static int ExecuteCommand(string cmd, bool isSystem, string fullPath) 263 | { 264 | if (cmd == "a" || cmd == "add") 265 | { 266 | if (isSystem) 267 | AddToPath.Program.AddToSystemPath(fullPath); 268 | else 269 | AddToPath.Program.AddToUserPath(fullPath); 270 | 271 | return 0; 272 | } 273 | else if (cmd == "r" || cmd == "remove") 274 | { 275 | if (isSystem) 276 | AddToPath.Program.RemoveFromSystemPath(fullPath); 277 | else 278 | AddToPath.Program.RemoveFromUserPath(fullPath); 279 | 280 | return 0; 281 | } 282 | else 283 | { 284 | ShowUsage(); 285 | return 1; 286 | } 287 | } 288 | 289 | static void ShowUsage() 290 | { 291 | Console.WriteLine("AddToPath CLI Tool Usage:"); 292 | Console.WriteLine(" a2p [scope] "); 293 | Console.WriteLine(); 294 | Console.WriteLine("Commands:"); 295 | Console.WriteLine(" a, add Add a directory to PATH"); 296 | Console.WriteLine(" r, remove Remove a directory from PATH"); 297 | Console.WriteLine(" l, list List all PATH entries"); 298 | Console.WriteLine(" c, check Check if a directory is in PATH"); 299 | Console.WriteLine(" h, help Show this help message"); 300 | Console.WriteLine(); 301 | Console.WriteLine("Scope (required for add/remove):"); 302 | Console.WriteLine(" u, user Modify user PATH"); 303 | Console.WriteLine(" s, system Modify system PATH (requires admin)"); 304 | Console.WriteLine(); 305 | Console.WriteLine("Examples:"); 306 | Console.WriteLine(" a2p add user C:\\MyTools Add to user PATH"); 307 | Console.WriteLine(" a2p remove system C:\\MyTools Remove from system PATH"); 308 | Console.WriteLine(" a2p list Show all PATH entries"); 309 | Console.WriteLine(" a2p check C:\\MyTools Check if directory is in PATH"); 310 | } 311 | 312 | static void ShowPaths() 313 | { 314 | var userPaths = AddToPath.Program.GetUserPaths(); 315 | var systemPaths = AddToPath.Program.GetSystemPaths(); 316 | 317 | Console.WriteLine("User PATH:"); 318 | foreach (var path in userPaths) 319 | Console.WriteLine($" {path}"); 320 | 321 | Console.WriteLine("\nSystem PATH:"); 322 | foreach (var path in systemPaths) 323 | Console.WriteLine($" {path}"); 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/a2p/a2p.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net472 6 | ..\AddToPath\Images\AddToPath.ico 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | $(SolutionDir)bin\$(Configuration) 16 | 17 | 18 | 19 | 20 | 21 | 22 | --------------------------------------------------------------------------------