├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | A Windows utility for managing your PATH environment variable through both a context menu and command line interface.
14 |
15 |
16 |
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 |
--------------------------------------------------------------------------------