├── .gitattributes ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── LICENSE ├── README.md ├── appveyor.yml ├── docs └── img │ └── menu-item.png ├── src └── NgrokExtensionsSolution │ ├── NgrokExtensions.2022 │ ├── Key.snk │ ├── LICENSE │ ├── NgrokExtensions.2022.csproj │ ├── Properties │ │ └── AssemblyInfo.cs │ └── source.extension.vsixmanifest │ ├── NgrokExtensions.Shared │ ├── Key.snk │ ├── NgrokErrorApiResult.cs │ ├── NgrokExtensions.Shared.projitems │ ├── NgrokExtensions.Shared.shproj │ ├── NgrokInstaller.cs │ ├── NgrokProcess.cs │ ├── NgrokTunnelApiRequest.cs │ ├── NgrokTunnelsApiResponse.cs │ ├── NgrokUtils.cs │ ├── OptionsPageGrid.cs │ ├── Resources │ │ ├── PreviewImage.png │ │ ├── StartTunnel.png │ │ └── tunnel.ico │ ├── StartTunnel.cs │ ├── StartTunnelPackage.cs │ ├── StartTunnelPackage.vsct │ ├── StartTunnelPackage1.cs │ ├── StartTunnelPackage2.cs │ ├── VSPackage.resx │ └── WebAppConfig.cs │ ├── NgrokExtensions.Test │ ├── FakeNgrokProcess.cs │ ├── IErrorDisplayFunc.cs │ ├── NgrokExtensions.Test.csproj │ ├── NgrokInstallerTest.cs │ ├── NgrokUtilsTest.cs │ ├── Properties │ │ └── AssemblyInfo.cs │ └── WebAppConfigTest.cs │ ├── NgrokExtensions │ ├── Key.snk │ ├── LICENSE │ ├── NgrokErrorApiResult.cs │ ├── NgrokExtensions.2019.csproj │ ├── NgrokInstaller.cs │ ├── NgrokProcess.cs │ ├── NgrokTunnelApiRequest.cs │ ├── NgrokTunnelsApiResponse.cs │ ├── NgrokUtils.cs │ ├── OptionsPageGrid.cs │ ├── Properties │ │ └── AssemblyInfo.cs │ ├── Resources │ │ ├── PreviewImage.png │ │ ├── StartTunnel.png │ │ └── tunnel.ico │ ├── StartTunnel.cs │ ├── StartTunnelPackage.cs │ ├── StartTunnelPackage.vsct │ ├── VSPackage.resx │ ├── WebAppConfig.cs │ └── source.extension.vsixmanifest │ └── NgrokExtensionsSolution.sln ├── vs-publish.json └── vs-publish.ps1 /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | name: "Build" 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | - multiversion 9 | paths-ignore: 10 | - '**/*.md' 11 | - '**/*.gitignore' 12 | - '**/*.gitattributes' 13 | pull_request: 14 | branches: 15 | - master 16 | paths-ignore: 17 | - '**/*.md' 18 | - '**/*.gitignore' 19 | - '**/*.gitattributes' 20 | workflow_dispatch: 21 | branches: 22 | - master 23 | - multiversion 24 | paths-ignore: 25 | - '**/*.md' 26 | - '**/*.gitignore' 27 | - '**/*.gitattributes' 28 | 29 | jobs: 30 | build: 31 | name: Build 32 | runs-on: windows-2022 33 | env: 34 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 35 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 36 | DOTNET_NOLOGO: true 37 | DOTNET_GENERATE_ASPNET_CERTIFICATE: false 38 | DOTNET_ADD_GLOBAL_TOOLS_TO_PATH: false 39 | DOTNET_MULTILEVEL_LOOKUP: 0 40 | 41 | steps: 42 | - uses: actions/checkout@v2 43 | 44 | - name: Setup MSBuild 45 | uses: microsoft/setup-msbuild@v1.1 46 | 47 | - name: Setup test 48 | uses: darenm/Setup-VSTest@v1 49 | 50 | - name: Build solution 51 | run: msbuild src/NgrokExtensionsSolution/NgrokExtensionsSolution.sln /p:Configuration=Release /v:m -restore /p:OutDir=../../../built 52 | 53 | - name: Test 54 | run: vstest.console.exe built\*test.dll 55 | 56 | - name: Upload artifact 57 | uses: actions/upload-artifact@v2 58 | with: 59 | name: NgrokExtensions.vsix 60 | path: built/**/*.vsix -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | [Xx]64/ 19 | [Xx]86/ 20 | [Bb]uild/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | 85 | # Visual Studio profiler 86 | *.psess 87 | *.vsp 88 | *.vspx 89 | *.sap 90 | 91 | # TFS 2012 Local Workspace 92 | $tf/ 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | *.DotSettings.user 101 | 102 | # JustCode is a .NET coding add-in 103 | .JustCode 104 | 105 | # TeamCity is a build add-in 106 | _TeamCity* 107 | 108 | # DotCover is a Code Coverage Tool 109 | *.dotCover 110 | 111 | # NCrunch 112 | _NCrunch_* 113 | .*crunch*.local.xml 114 | nCrunchTemp_* 115 | 116 | # MightyMoose 117 | *.mm.* 118 | AutoTest.Net/ 119 | 120 | # Web workbench (sass) 121 | .sass-cache/ 122 | 123 | # Installshield output folder 124 | [Ee]xpress/ 125 | 126 | # DocProject is a documentation generator add-in 127 | DocProject/buildhelp/ 128 | DocProject/Help/*.HxT 129 | DocProject/Help/*.HxC 130 | DocProject/Help/*.hhc 131 | DocProject/Help/*.hhk 132 | DocProject/Help/*.hhp 133 | DocProject/Help/Html2 134 | DocProject/Help/html 135 | 136 | # Click-Once directory 137 | publish/ 138 | 139 | # Publish Web Output 140 | *.[Pp]ublish.xml 141 | *.azurePubxml 142 | 143 | # TODO: Un-comment the next line if you do not want to checkin 144 | # your web deploy settings because they may include unencrypted 145 | # passwords 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # NuGet Packages 150 | *.nupkg 151 | # The packages folder can be ignored because of Package Restore 152 | **/packages/* 153 | # except build/, which is used as an MSBuild target. 154 | !**/packages/build/ 155 | # Uncomment if necessary however generally it will be regenerated when needed 156 | #!**/packages/repositories.config 157 | # NuGet v3's project.json files produces more ignoreable files 158 | *.nuget.props 159 | *.nuget.targets 160 | 161 | # Microsoft Azure Build Output 162 | csx/ 163 | *.build.csdef 164 | 165 | # Microsoft Azure Emulator 166 | ecf/ 167 | rcf/ 168 | 169 | # Windows Store app package directory 170 | AppPackages/ 171 | BundleArtifacts/ 172 | 173 | # Visual Studio cache files 174 | # files ending in .cache can be ignored 175 | *.[Cc]ache 176 | # but keep track of directories ending in .cache 177 | !*.[Cc]ache/ 178 | 179 | # Others 180 | ClientBin/ 181 | [Ss]tyle[Cc]op.* 182 | ~$* 183 | *~ 184 | *.dbmdl 185 | *.dbproj.schemaview 186 | *.pfx 187 | *.publishsettings 188 | node_modules/ 189 | orleans.codegen.cs 190 | 191 | # RIA/Silverlight projects 192 | Generated_Code/ 193 | 194 | # Backup & report files from converting an old project file 195 | # to a newer Visual Studio version. Backup files are not needed, 196 | # because we have git ;-) 197 | _UpgradeReport_Files/ 198 | Backup*/ 199 | UpgradeLog*.XML 200 | UpgradeLog*.htm 201 | 202 | # SQL Server files 203 | *.mdf 204 | *.ldf 205 | 206 | # Business Intelligence projects 207 | *.rdl.data 208 | *.bim.layout 209 | *.bim_*.settings 210 | 211 | # Microsoft Fakes 212 | FakesAssemblies/ 213 | 214 | # GhostDoc plugin setting file 215 | *.GhostDoc.xml 216 | 217 | # Node.js Tools for Visual Studio 218 | .ntvs_analysis.dat 219 | 220 | # Visual Studio 6 build log 221 | *.plg 222 | 223 | # Visual Studio 6 workspace options file 224 | *.opt 225 | 226 | # Visual Studio LightSwitch build output 227 | **/*.HTMLClient/GeneratedArtifacts 228 | **/*.DesktopClient/GeneratedArtifacts 229 | **/*.DesktopClient/ModelManifest.xml 230 | **/*.Server/GeneratedArtifacts 231 | **/*.Server/ModelManifest.xml 232 | _Pvt_Extensions 233 | 234 | # LightSwitch generated files 235 | GeneratedArtifacts/ 236 | ModelManifest.xml 237 | 238 | # Paket dependency manager 239 | .paket/paket.exe 240 | 241 | # FAKE - F# Make 242 | .fake/ 243 | /src/NgrokExtensionsSolution/MigrationBackup 244 | /built 245 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 David Prothero 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 | # Ngrok Extensions for Visual Studio 2 | 3 | [![Build status](https://ci.appveyor.com/api/projects/status/mi2kn7oaluldhuyo/branch/master?svg=true)](https://ci.appveyor.com/project/dprothero/ngrokextensions/branch/master) 4 | 5 | ## Description 6 | 7 | This extension allows you to use [ngrok](https://ngrok.com) right from within Visual Studio. 8 | It will start a tunnel for each web application that is part of your solution. 9 | 10 | Has been tested in Visual Studio 2015, 2017, 2019, and 2022. 11 | 12 | ## Installation 13 | 14 | ### From Visual Studio 15 | 16 | Get it from the [Visual Studio Gallery for VS 2022](https://marketplace.visualstudio.com/items?itemName=DavidProthero.ngrokextensions-2022) or for [VS 2019 and earlier](https://marketplace.visualstudio.com/items?itemName=DavidProthero.NgrokExtensions). 17 | From within Visual Studio: 18 | 19 | 1. Select Tools... Extensions and Updates... 20 | 2. Click "Online" and select the "Visual Studio Gallery" along the left-hand side of the window. 21 | 3. Type "ngrok" into the search box in the upper right. 22 | 4. Click the "Download" button on the extension in the search results. 23 | 24 | ### Build from Source 25 | 26 | 1. Clone this repo 27 | 2. Open with Visual Studio 2017 and build a `Release` build 28 | 3. Find the .vsix file in the `bin\Release` folder 29 | 4. Double-click the .vsix file to install 30 | 31 | ## Usage 32 | 33 | Currently, usage is super-simple. All you need to do is open a solution with 34 | one or more web projects and then choose "Start ngrok Tunnel" from the "Tools" 35 | menu. 36 | 37 | ![Menu item](https://raw.githubusercontent.com/dprothero/NgrokExtensions/master/docs/img/menu-item.png) 38 | 39 | ### Custom ngrok Subdomains 40 | 41 | If you have a paid ngrok account, you can make use of custom subdomains with 42 | this extension. 43 | 44 | Specify the subdomain you would like it to use in a `ngrok.subdomain` key 45 | in the `appSettings` section of your `web.config` file like so: 46 | 47 | ```xml 48 | 49 | 50 | 51 | 52 | ... more appSettings keys omitted ... 53 | 54 | ... more config omitted ... 55 | 56 | ``` 57 | 58 | #### Custom ngrok Subdomains with ASP.NET Core or Azure Functions 59 | 60 | If you are using an ASP.NET Core or Azure Functions project and want to test locally, you can set the 61 | `ngrok.subdomain` key in the `appsettings.json` file like so: 62 | 63 | ```json 64 | { 65 | "IsEncrypted": false, 66 | "Values": { 67 | "ngrok.subdomain": "my-cool-app", 68 | ... more app settings omitted ... 69 | } 70 | } 71 | ``` 72 | 73 | You can also set this value in a `secrets.json` file as [described here](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?tabs=visual-studio). 74 | 75 | ## Feedback and Contribution 76 | 77 | This is a brand new extension and would benefit greatly from your feedback 78 | and even your code contribution. 79 | 80 | If you find a bug or would like to request a feature, 81 | [open an issue](https://github.com/twilio-labs/NgrokExtensions/issues). 82 | 83 | To contribute, fork this repo to your own GitHub account. Then, create a 84 | branch on your own fork and perform the work. Push it up to your fork and 85 | then submit a Pull Request to this repo. This is called [GitHub Flow](https://guides.github.com/introduction/flow/). 86 | 87 | ## Change Log 88 | 89 | * v0.9.14 - Add support for Visual Studio 2022. 90 | * v0.9.13 - Add support for https. 91 | * v0.9.12 - Add support for Visual Studio 2019. 92 | * v0.9.11 - Fix ngrok installer after ngrok download page changed. 93 | * v0.9.10 - Allow settings override in secrets.json. Thanks @ChristopherHaws! 94 | * v0.9.9 - Bug fixes. Find projects within Solution folders. 95 | * v0.9.8 - Bug fixes. Automatically install ngrok.exe if not found. 96 | * v0.9.7 - Support for ASP.NET Core projects. Thanks @ahanoff! 97 | * v0.9.6 - Added support for Visual Studio 2017. 98 | * v0.9.5 - Added support for Azure Function projects. 99 | * v0.9.4 - Added support for Node.js projects. 100 | * v0.9.3 - Fix crash when decimal values in ngrok's JSON response. 101 | * v0.9.2 - Allow customizing location of ngrok.exe. 102 | * v0.9.1 - Initial Release 103 | 104 | * * * 105 | 106 | Licensed under the MIT license. See the LICENSE file in the project root for more information. 107 | 108 | Copyright (c) 2023 David Prothero 109 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 1.0.{build} 2 | image: Visual Studio 2017 3 | configuration: Release 4 | before_build: 5 | - cmd: >- 6 | cd .\src\NgrokExtensionsSolution 7 | 8 | nuget restore 9 | build: 10 | project: src/NgrokExtensionsSolution/NgrokExtensionsSolution.sln 11 | verbosity: minimal 12 | artifacts: 13 | - path: src/NgrokExtensionsSolution/NgrokExtensions/bin/Release/NgrokExtensions.vsix 14 | name: Release -------------------------------------------------------------------------------- /docs/img/menu-item.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/docs/img/menu-item.png -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.2022/Key.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions.2022/Key.snk -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.2022/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 David Prothero 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 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.2022/NgrokExtensions.2022.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 17.0 5 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 6 | 7 | 8 | true 9 | 10 | 11 | Key.snk 12 | 13 | 14 | 15 | Debug 16 | AnyCPU 17 | 2.0 18 | {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 19 | {BF64FAC8-EF65-43EE-8229-21565F95FD68} 20 | Library 21 | Properties 22 | NgrokExtensions 23 | NgrokExtensions.2022 24 | v4.7.2 25 | true 26 | true 27 | true 28 | false 29 | false 30 | true 31 | true 32 | Program 33 | $(DevEnvDir)devenv.exe 34 | /rootsuffix Exp 35 | 36 | 37 | true 38 | full 39 | false 40 | bin\Debug\ 41 | DEBUG;TRACE 42 | prompt 43 | 4 44 | 45 | 46 | pdbonly 47 | true 48 | bin\Release\ 49 | TRACE 50 | prompt 51 | 4 52 | 53 | 54 | 55 | NgrokErrorApiResult.cs 56 | 57 | 58 | NgrokInstaller.cs 59 | 60 | 61 | NgrokProcess.cs 62 | 63 | 64 | NgrokTunnelApiRequest.cs 65 | 66 | 67 | NgrokTunnelsApiResponse.cs 68 | 69 | 70 | NgrokUtils.cs 71 | 72 | 73 | OptionsPageGrid.cs 74 | Component 75 | 76 | 77 | StartTunnel.cs 78 | 79 | 80 | StartTunnelPackage.cs 81 | 82 | 83 | WebAppConfig.cs 84 | 85 | 86 | 87 | 88 | 89 | Resources\PreviewImage.png 90 | Always 91 | true 92 | 93 | 94 | Resources\StartTunnel.png 95 | 96 | 97 | Resources\tunnel.ico 98 | Always 99 | true 100 | 101 | 102 | Menus.ctmenu 103 | 104 | 105 | Always 106 | true 107 | 108 | 109 | 110 | Designer 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 5.2.9 123 | 124 | 125 | compile; build; native; contentfiles; analyzers; buildtransitive 126 | 127 | 128 | runtime; build; native; contentfiles; analyzers; buildtransitive 129 | all 130 | 131 | 132 | 4.3.4 133 | 134 | 135 | 6.0.0 136 | 137 | 138 | 139 | 140 | VSPackage.resx 141 | 142 | 143 | 144 | 145 | 152 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.2022/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.InteropServices; 3 | 4 | // General Information about an assembly is controlled through the following 5 | // set of attributes. Change these attribute values to modify the information 6 | // associated with an assembly. 7 | [assembly: AssemblyTitle("NgrokExtensions")] 8 | [assembly: AssemblyDescription("Add ngrok to Visual Studio")] 9 | [assembly: AssemblyConfiguration("")] 10 | [assembly: AssemblyCompany("David Prothero")] 11 | [assembly: AssemblyProduct("NgrokExtensions")] 12 | [assembly: AssemblyCopyright("Copyright 2023 David Prothero (MIT License)")] 13 | [assembly: AssemblyTrademark("")] 14 | [assembly: AssemblyCulture("")] 15 | 16 | // Setting ComVisible to false makes the types in this assembly not visible 17 | // to COM components. If you need to access a type in this assembly from 18 | // COM, set the ComVisible attribute to true on that type. 19 | [assembly: ComVisible(false)] 20 | 21 | // Version information for an assembly consists of the following four values: 22 | // 23 | // Major Version 24 | // Minor Version 25 | // Build Number 26 | // Revision 27 | // 28 | // You can specify all the values or you can default the Build and Revision Numbers 29 | // by using the '*' as shown below: 30 | // [assembly: AssemblyVersion("1.0.*")] 31 | [assembly: AssemblyVersion("0.9.14.0")] 32 | [assembly: AssemblyFileVersion("0.9.14.0")] 33 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.2022/source.extension.vsixmanifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ngrok Extensions 2022 6 | Use ngrok quickly and easily from within Visual Studio. ngrok allows you to expose a local server behind a NAT or firewall to the internet. "Demo without deploying." 7 | https://github.com/twilio-labs/NgrokExtensions 8 | LICENSE 9 | Resources\tunnel.ico 10 | Resources\PreviewImage.png 11 | Visual Studio, Extension, Web, ngrok, tunnel 12 | 13 | 14 | 15 | amd64 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/Key.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions.Shared/Key.snk -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokErrorApiResult.cs: -------------------------------------------------------------------------------- 1 | namespace NgrokExtensions 2 | { 3 | public class NgrokErrorApiResult 4 | { 5 | public int error_code { get; set; } 6 | public int status_code { get; set; } 7 | public string msg { get; set; } 8 | public Details details { get; set; } 9 | } 10 | 11 | public class Details 12 | { 13 | public string err { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokExtensions.Shared.projitems: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 5 | true 6 | 386561ba-7f3a-4682-9288-d2efe29daaea 7 | 8 | 9 | NgrokExtensions.Shared 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Component 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Always 28 | 29 | 30 | 31 | Always 32 | 33 | 34 | 35 | 36 | Designer 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokExtensions.Shared.shproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 386561ba-7f3a-4682-9288-d2efe29daaea 5 | 14.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokInstaller.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Net.Http; 5 | using System.Text.RegularExpressions; 6 | using System.Threading.Tasks; 7 | 8 | namespace NgrokExtensions 9 | { 10 | public class NgrokDownloadException : Exception 11 | { 12 | public NgrokDownloadException(string message) : base(message) 13 | { 14 | 15 | } 16 | } 17 | 18 | public class NgrokInstaller 19 | { 20 | private readonly HttpClient _httpClient; 21 | private readonly bool _is64Bit; 22 | 23 | public NgrokInstaller() 24 | { 25 | _httpClient = new HttpClient(); 26 | _is64Bit = Environment.Is64BitOperatingSystem; 27 | } 28 | 29 | public NgrokInstaller(HttpClient httpClient, bool is64Bit) 30 | { 31 | _httpClient = httpClient; 32 | _is64Bit = is64Bit; 33 | } 34 | 35 | public async Task GetNgrokDownloadUrlAsync() 36 | { 37 | var response = await _httpClient.GetAsync("https://ngrok.com/download"); 38 | 39 | if (!response.IsSuccessStatusCode) 40 | { 41 | throw new NgrokDownloadException($"Error retrieving ngrok download page. ({response.StatusCode})"); 42 | } 43 | 44 | var html = await response.Content.ReadAsStringAsync(); 45 | 46 | var downloadLinkId = "windows-dl-link"; 47 | var pattern = @"id=""" + downloadLinkId + 48 | @"""(?:.|\s)*?[^>]+?href=""(http[s]?:\/\/[^""]*?)"""; 49 | 50 | var match = Regex.Match(html, pattern); 51 | 52 | if (!match.Success) 53 | { 54 | throw new NgrokDownloadException("Could not find ngrok download URL."); 55 | } 56 | 57 | var archDownloadUri = match.Groups[1].Value.Replace("&", "&"); 58 | 59 | // 07-Mar-2022: ngrok has dynamic download page now. This is a change to replace the arch version in their download URI 60 | if (!_is64Bit) archDownloadUri = archDownloadUri.Replace("amd64", "386"); 61 | 62 | return archDownloadUri; 63 | } 64 | 65 | public async Task DownloadNgrokAsync(string url = null) 66 | { 67 | if (string.IsNullOrEmpty(url)) 68 | { 69 | url = await GetNgrokDownloadUrlAsync(); 70 | } 71 | 72 | var response = await _httpClient.GetAsync(url); 73 | 74 | if (!response.IsSuccessStatusCode) 75 | { 76 | throw new NgrokDownloadException($"Error trying to download {url}. ({response.StatusCode})"); 77 | } 78 | 79 | return await response.Content.ReadAsStreamAsync(); 80 | } 81 | 82 | public async Task InstallNgrokAsync() 83 | { 84 | var zip = new ZipArchive(await DownloadNgrokAsync(), ZipArchiveMode.Read); 85 | var exeEntry = zip.GetEntry("ngrok.exe"); 86 | using (var exeData = exeEntry.Open()) 87 | { 88 | var ngrokPath = Path.GetFullPath(".\\ngrok.exe"); 89 | using (var exeFile = File.Create(ngrokPath)) 90 | { 91 | await exeData.CopyToAsync(exeFile); 92 | return ngrokPath; 93 | } 94 | } 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokProcess.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace NgrokExtensions 8 | { 9 | public class NgrokProcess 10 | { 11 | private static readonly Regex VersionPattern = new Regex(@"\d+\.\d+\.\d+"); 12 | private readonly string _exePath; 13 | private Process _osProcess; 14 | 15 | public NgrokProcess(string exePath) 16 | { 17 | _exePath = exePath; 18 | } 19 | 20 | public string GetNgrokVersion() 21 | { 22 | StartNgrokProcess("--version", false); 23 | var version = GetStandardOutput(); 24 | WaitForExit(); 25 | 26 | var match = VersionPattern.Match(version); 27 | return match.Success ? match.Value : null; 28 | } 29 | 30 | public void StartNgrokProcess(string args = "start --none", bool showWindow = true) 31 | { 32 | var path = GetNgrokPath(); 33 | 34 | var pi = new ProcessStartInfo(path, args) 35 | { 36 | CreateNoWindow = !showWindow, 37 | WindowStyle = showWindow ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden, 38 | RedirectStandardOutput = !showWindow, 39 | UseShellExecute = showWindow 40 | }; 41 | 42 | Start(pi); 43 | } 44 | 45 | private string GetNgrokPath() 46 | { 47 | var path = "ngrok.exe"; 48 | 49 | if (!string.IsNullOrWhiteSpace(_exePath) && File.Exists(_exePath)) 50 | { 51 | path = _exePath; 52 | } 53 | 54 | return path; 55 | } 56 | 57 | protected virtual void Start(ProcessStartInfo pi) 58 | { 59 | _osProcess = Process.Start(pi); 60 | } 61 | 62 | protected virtual string GetStandardOutput() 63 | { 64 | return _osProcess.StandardOutput.ReadToEnd(); 65 | } 66 | 67 | protected virtual void WaitForExit() 68 | { 69 | _osProcess.WaitForExit(); 70 | } 71 | 72 | public bool IsInstalled() 73 | { 74 | var fileName = GetNgrokPath(); 75 | 76 | if (File.Exists(fileName)) 77 | return true; 78 | 79 | var values = Environment.GetEnvironmentVariable("PATH") ?? ""; 80 | return values.Split(Path.PathSeparator) 81 | .Select(path => Path.Combine(path, fileName)) 82 | .Any(File.Exists); 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokTunnelApiRequest.cs: -------------------------------------------------------------------------------- 1 | namespace NgrokExtensions 2 | { 3 | public class NgrokTunnelApiRequest 4 | { 5 | public string name { get; set; } 6 | public string addr { get; set; } 7 | public string proto { get; set; } 8 | public string subdomain { get; set; } 9 | public string host_header { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokTunnelsApiResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NgrokExtensions 2 | { 3 | 4 | public class NgrokTunnelsApiResponse 5 | { 6 | public Tunnel[] tunnels { get; set; } 7 | public string uri { get; set; } 8 | } 9 | 10 | public class Tunnel 11 | { 12 | public string name { get; set; } 13 | public string uri { get; set; } 14 | public string public_url { get; set; } 15 | public string proto { get; set; } 16 | public Config config { get; set; } 17 | public Metrics metrics { get; set; } 18 | } 19 | 20 | public class Config 21 | { 22 | public string addr { get; set; } 23 | public bool inspect { get; set; } 24 | } 25 | 26 | public class Metrics 27 | { 28 | public Conns conns { get; set; } 29 | public Http http { get; set; } 30 | } 31 | 32 | public class Conns 33 | { 34 | public int count { get; set; } 35 | public int gauge { get; set; } 36 | public decimal rate1 { get; set; } 37 | public decimal rate5 { get; set; } 38 | public decimal rate15 { get; set; } 39 | public decimal p50 { get; set; } 40 | public decimal p90 { get; set; } 41 | public decimal p95 { get; set; } 42 | public decimal p99 { get; set; } 43 | } 44 | 45 | public class Http 46 | { 47 | public int count { get; set; } 48 | public decimal rate1 { get; set; } 49 | public decimal rate5 { get; set; } 50 | public decimal rate15 { get; set; } 51 | public decimal p50 { get; set; } 52 | public decimal p90 { get; set; } 53 | public decimal p95 { get; set; } 54 | public decimal p99 { get; set; } 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/NgrokUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Net.Http; 8 | using System.Net.Http.Headers; 9 | using System.Net.Http.Json; 10 | using System.Threading.Tasks; 11 | using Newtonsoft.Json; 12 | 13 | namespace NgrokExtensions 14 | { 15 | public class NgrokUtils 16 | { 17 | public const string NgrokNotFoundMessage = "ngrok executable not found. Configure the path in the via the add-in options or add the location to your PATH."; 18 | private static readonly Version MinimumVersion = new Version("2.3.34"); 19 | private readonly Dictionary _webApps; 20 | private readonly Func _showErrorFunc; 21 | private readonly HttpClient _ngrokApi; 22 | private Tunnel[] _tunnels; 23 | private readonly NgrokProcess _ngrokProcess; 24 | 25 | public NgrokUtils(Dictionary webApps, string exePath, 26 | Func asyncShowErrorFunc, 27 | HttpClient client = null, NgrokProcess ngrokProcess = null) 28 | { 29 | _webApps = webApps; 30 | _ngrokProcess = ngrokProcess ?? new NgrokProcess(exePath); 31 | _showErrorFunc = asyncShowErrorFunc; 32 | _ngrokApi = client ?? new HttpClient(); 33 | _ngrokApi.BaseAddress = new Uri("http://localhost:4040"); 34 | _ngrokApi.DefaultRequestHeaders.Accept.Clear(); 35 | _ngrokApi.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 36 | } 37 | 38 | public bool NgrokIsInstalled() 39 | { 40 | if (!_ngrokProcess.IsInstalled()) return false; 41 | 42 | var versionString = _ngrokProcess.GetNgrokVersion(); 43 | if (versionString == null) return false; 44 | 45 | var version = new Version(versionString); 46 | return version.CompareTo(MinimumVersion) >= 0; 47 | } 48 | 49 | public async Task StartTunnelsAsync() 50 | { 51 | Exception uncaughtException = null; 52 | 53 | try 54 | { 55 | await DoStartTunnelsAsync(); 56 | } 57 | catch (FileNotFoundException) 58 | { 59 | await _showErrorFunc(NgrokNotFoundMessage); 60 | } 61 | catch (Win32Exception ex) 62 | { 63 | if (ex.ErrorCode.ToString("X") == "80004005") 64 | { 65 | await _showErrorFunc(NgrokNotFoundMessage); 66 | } 67 | else 68 | { 69 | uncaughtException = ex; 70 | } 71 | } 72 | catch (Exception ex) 73 | { 74 | uncaughtException = ex; 75 | } 76 | 77 | if (uncaughtException != null) 78 | { 79 | await _showErrorFunc($"Ran into a problem trying to start the ngrok tunnel(s): {uncaughtException}"); 80 | } 81 | } 82 | 83 | private async Task DoStartTunnelsAsync() 84 | { 85 | await StartNgrokAsync(); 86 | foreach (var projectName in _webApps.Keys) 87 | { 88 | await StartNgrokTunnelAsync(projectName, _webApps[projectName]); 89 | } 90 | } 91 | 92 | private async Task StartNgrokAsync(bool retry = false) 93 | { 94 | if (await CanGetTunnelListAsync()) return; 95 | 96 | _ngrokProcess.StartNgrokProcess(); 97 | await Task.Delay(250); 98 | 99 | if (await CanGetTunnelListAsync(retry:true)) return; 100 | await _showErrorFunc("Cannot start ngrok. Is it installed and in your PATH?"); 101 | } 102 | 103 | private async Task CanGetTunnelListAsync(bool retry = false) 104 | { 105 | try 106 | { 107 | await GetTunnelListAsync(); 108 | } 109 | catch 110 | { 111 | if (retry) throw; 112 | } 113 | return (_tunnels != null); 114 | } 115 | 116 | private async Task GetTunnelListAsync() 117 | { 118 | var response = await _ngrokApi.GetAsync("/api/tunnels"); 119 | if (response.IsSuccessStatusCode) 120 | { 121 | var responseText = await response.Content.ReadAsStringAsync(); 122 | Debug.WriteLine($"responseText: '{responseText}'"); 123 | var apiResponse = JsonConvert.DeserializeObject(responseText); 124 | _tunnels = apiResponse.tunnels; 125 | } 126 | } 127 | 128 | private async Task StartNgrokTunnelAsync(string projectName, WebAppConfig config) 129 | { 130 | var addr = config.NgrokAddress; 131 | if (!TunnelAlreadyExists(addr)) 132 | { 133 | await CreateTunnelAsync(projectName, config, addr); 134 | } 135 | } 136 | 137 | private bool TunnelAlreadyExists(string addr) 138 | { 139 | return _tunnels.Any(t => t.config.addr == addr); 140 | } 141 | 142 | private string StripProtocol(string addr) 143 | { 144 | return addr.Replace("https://", ""); 145 | } 146 | 147 | private async Task CreateTunnelAsync(string projectName, WebAppConfig config, string addr, bool retry = false) 148 | { 149 | var request = new NgrokTunnelApiRequest 150 | { 151 | name = projectName, 152 | addr = addr, 153 | proto = "http", 154 | host_header = StripProtocol(addr) 155 | }; 156 | if (!string.IsNullOrEmpty(config.SubDomain)) 157 | { 158 | request.subdomain = config.SubDomain; 159 | } 160 | 161 | Debug.WriteLine($"request: '{JsonConvert.SerializeObject(request)}'"); 162 | var response = await _ngrokApi.PostAsJsonAsync("/api/tunnels", request); 163 | if (!response.IsSuccessStatusCode) 164 | { 165 | var errorText = await response.Content.ReadAsStringAsync(); 166 | Debug.WriteLine($"{response.StatusCode} errorText: '{errorText}'"); 167 | NgrokErrorApiResult error; 168 | 169 | try 170 | { 171 | error = JsonConvert.DeserializeObject(errorText); 172 | } 173 | catch(JsonReaderException) 174 | { 175 | error = null; 176 | } 177 | 178 | if (error != null) 179 | { 180 | await _showErrorFunc($"Could not create tunnel for {projectName} ({addr}): " + 181 | $"\n[{error.error_code}] {error.msg}" + 182 | $"\nDetails: {error.details.err.Replace("\\n", "\n")}"); 183 | } 184 | else 185 | { 186 | if (retry) 187 | { 188 | await _showErrorFunc($"Could not create tunnel for {projectName} ({addr}): " + 189 | $"\n{errorText}"); 190 | } 191 | else 192 | { 193 | await Task.Delay(1000); // wait for ngrok to spin up completely? 194 | await CreateTunnelAsync(projectName, config, addr, true); 195 | } 196 | } 197 | return; 198 | } 199 | 200 | var responseText = await response.Content.ReadAsStringAsync(); 201 | Debug.WriteLine($"responseText: '{responseText}'"); 202 | var tunnel = JsonConvert.DeserializeObject(responseText); 203 | config.PublicUrl = tunnel.public_url; 204 | Debug.WriteLine(config.PublicUrl); 205 | } 206 | } 207 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/OptionsPageGrid.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace NgrokExtensions 9 | { 10 | public class OptionsPageGrid : Microsoft.VisualStudio.Shell.DialogPage 11 | { 12 | private string executablePath = ""; 13 | 14 | [Category("ngrok")] 15 | [DisplayName("Executable Path")] 16 | [Description("Full path to the ngrok executable")] 17 | public string ExecutablePath 18 | { 19 | get { return executablePath; } 20 | set { executablePath = value; } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/Resources/PreviewImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions.Shared/Resources/PreviewImage.png -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/Resources/StartTunnel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions.Shared/Resources/StartTunnel.png -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/Resources/tunnel.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions.Shared/Resources/tunnel.ico -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/StartTunnel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.Design; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using System.Xml.Linq; 9 | using EnvDTE; 10 | using EnvDTE80; 11 | using Microsoft.VisualStudio.Shell; 12 | using Microsoft.VisualStudio.Shell.Interop; 13 | using Microsoft.VisualStudio.Threading; 14 | using Newtonsoft.Json; 15 | 16 | namespace NgrokExtensions 17 | { 18 | internal sealed class StartTunnel 19 | { 20 | private static readonly HashSet PortPropertyNames = new HashSet 21 | { 22 | "WebApplication.DevelopmentServerPort", 23 | "WebApplication.IISUrl", 24 | "WebApplication.CurrentDebugUrl", 25 | "WebApplication.NonSecureUrl", 26 | "WebApplication.BrowseURL", 27 | "NodejsPort", // Node.js project 28 | "FileName", // Azure functions if ends with '.funproj' 29 | "ProjectUrl" 30 | }; 31 | 32 | public const int CommandId = 0x0100; 33 | private const string NgrokSubdomainSettingName = "ngrok.subdomain"; 34 | public static readonly Guid CommandSet = new Guid("30d1a36d-a03a-456d-b639-f28b9b23e161"); 35 | private readonly Package _package; 36 | 37 | /// 38 | /// Initializes a new instance of the class. 39 | /// Adds our command handlers for menu (commands must exist in the command table file) 40 | /// 41 | /// Owner package, not null. 42 | private StartTunnel(Package package) 43 | { 44 | if (package == null) 45 | { 46 | throw new ArgumentNullException(nameof(package)); 47 | } 48 | _package = package; 49 | 50 | var commandService = 51 | ServiceProvider.GetService(typeof(IMenuCommandService)) as OleMenuCommandService; 52 | if (commandService == null) return; 53 | 54 | var menuCommandId = new CommandID(CommandSet, CommandId); 55 | var menuItem = new MenuCommand(this.MenuItemCallback, menuCommandId); 56 | commandService.AddCommand(menuItem); 57 | } 58 | 59 | /// 60 | /// Gets the instance of the command. 61 | /// 62 | public static StartTunnel Instance { get; private set; } 63 | 64 | /// 65 | /// Gets the service provider from the owner package. 66 | /// 67 | private IServiceProvider ServiceProvider => _package; 68 | 69 | /// 70 | /// Initializes the singleton instance of the command. 71 | /// 72 | /// Owner package, not null. 73 | public static void Initialize(Package package) 74 | { 75 | Instance = new StartTunnel(package); 76 | } 77 | 78 | /// 79 | /// This function is the callback used to execute the command when the menu item is clicked. 80 | /// See the constructor to see how the menu item is associated with this function using 81 | /// OleMenuCommandService service and MenuCommand class. 82 | /// 83 | /// Event sender. 84 | /// Event args. 85 | private void MenuItemCallback(object sender, EventArgs e) 86 | { 87 | ThreadHelper.ThrowIfNotOnUIThread(); 88 | var webApps = GetWebApps(); 89 | 90 | if (webApps.Count == 0) 91 | { 92 | ShowErrorMessage("Did not find any Web projects."); 93 | return; 94 | } 95 | 96 | var page = (OptionsPageGrid)_package.GetDialogPage(typeof(OptionsPageGrid)); 97 | var ngrok = new NgrokUtils(webApps, page.ExecutablePath, ShowErrorMessageAsync); 98 | 99 | var installPlease = false; 100 | if (!ngrok.NgrokIsInstalled()) 101 | { 102 | if (AskUserYesNoQuestion( 103 | "Ngrok 2.3.34 or above is not installed. Would you like me to download it from ngrok.com and install it for you?")) 104 | { 105 | installPlease = true; 106 | } 107 | else 108 | { 109 | return; 110 | } 111 | } 112 | 113 | ThreadHelper.JoinableTaskFactory.Run(async delegate 114 | { 115 | await TaskScheduler.Default; 116 | if (installPlease) 117 | { 118 | try 119 | { 120 | var installer = new NgrokInstaller(); 121 | page.ExecutablePath = await installer.InstallNgrokAsync(); 122 | ngrok = new NgrokUtils(webApps, page.ExecutablePath, ShowErrorMessageAsync); 123 | } 124 | catch (NgrokDownloadException ngrokDownloadException) 125 | { 126 | await ShowErrorMessageAsync(ngrokDownloadException.Message); 127 | return; 128 | } 129 | } 130 | await ngrok.StartTunnelsAsync(); 131 | }); 132 | } 133 | 134 | private async System.Threading.Tasks.Task ShowErrorMessageAsync(string message) 135 | { 136 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); 137 | ShowErrorMessage(message); 138 | } 139 | 140 | private void ShowErrorMessage(string message) 141 | { 142 | VsShellUtilities.ShowMessageBox( 143 | this.ServiceProvider, 144 | message, 145 | "ngrok", 146 | OLEMSGICON.OLEMSGICON_CRITICAL, 147 | OLEMSGBUTTON.OLEMSGBUTTON_OK, 148 | OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); 149 | } 150 | 151 | private bool AskUserYesNoQuestion(string message) 152 | { 153 | var result = VsShellUtilities.ShowMessageBox( 154 | this.ServiceProvider, 155 | message, 156 | "ngrok", 157 | OLEMSGICON.OLEMSGICON_QUERY, 158 | OLEMSGBUTTON.OLEMSGBUTTON_YESNO, 159 | OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); 160 | 161 | return result == 6; // Yes 162 | } 163 | 164 | private Dictionary GetWebApps() 165 | { 166 | ThreadHelper.ThrowIfNotOnUIThread(); 167 | var webApps = new Dictionary(); 168 | var projects = GetSolutionProjects(); 169 | if (projects == null) return webApps; 170 | 171 | foreach (Project project in projects) 172 | { 173 | if (project.Properties == null) continue; // Project not loaded yet 174 | 175 | foreach (Property prop in project.Properties) 176 | { 177 | DebugWriteProp(prop); 178 | if (!PortPropertyNames.Contains(prop.Name)) continue; 179 | 180 | WebAppConfig webApp; 181 | 182 | if (prop.Name == "FileName") 183 | { 184 | if (prop.Value.ToString().EndsWith(".funproj")) 185 | { 186 | // Azure Functions app - use port 7071 187 | webApp = new WebAppConfig("7071"); 188 | LoadOptionsFromAppSettingsJson(project, webApp); 189 | } 190 | else 191 | { 192 | continue; // FileName property not relevant otherwise 193 | } 194 | } 195 | else 196 | { 197 | webApp = new WebAppConfig(prop.Value.ToString()); 198 | if (!webApp.IsValid) continue; 199 | if (IsAspNetCoreProject(prop.Name)) 200 | { 201 | LoadOptionsFromAppSettingsJson(project, webApp); 202 | } 203 | else 204 | { 205 | LoadOptionsFromWebConfig(project, webApp); 206 | } 207 | } 208 | 209 | webApps.Add(project.Name, webApp); 210 | break; 211 | } 212 | } 213 | return webApps; 214 | } 215 | 216 | private bool IsAspNetCoreProject(string propName) 217 | { 218 | return propName == "ProjectUrl"; 219 | } 220 | 221 | private static void LoadOptionsFromWebConfig(Project project, WebAppConfig webApp) 222 | { 223 | ThreadHelper.ThrowIfNotOnUIThread(); 224 | foreach (ProjectItem item in project.ProjectItems) 225 | { 226 | if (item.Name.ToLower() != "web.config") continue; 227 | 228 | var path = item.FileNames[0]; 229 | var webConfig = XDocument.Load(path); 230 | var appSettings = webConfig.Descendants("appSettings").FirstOrDefault(); 231 | webApp.SubDomain = appSettings?.Descendants("add") 232 | .FirstOrDefault(x => x.Attribute("key")?.Value == NgrokSubdomainSettingName) 233 | ?.Attribute("value")?.Value; 234 | break; 235 | } 236 | } 237 | 238 | private static void LoadOptionsFromAppSettingsJson(Project project, WebAppConfig webApp) 239 | { 240 | ThreadHelper.ThrowIfNotOnUIThread(); 241 | // Read the settings from the project's appsettings.json first 242 | foreach (ProjectItem item in project.ProjectItems) 243 | { 244 | if (item.Name.ToLower() != "appsettings.json") continue; 245 | 246 | ReadOptionsFromJsonFile(item.FileNames[0], webApp); 247 | } 248 | 249 | // Override any additional settings from the secrets.json file if it exists 250 | var userSecretsId = project.Properties.OfType() 251 | .FirstOrDefault(x => x.Name.Equals("UserSecretsId", StringComparison.OrdinalIgnoreCase))?.Value as String; 252 | 253 | if (string.IsNullOrEmpty(userSecretsId)) return; 254 | 255 | var appdata = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); 256 | var secretsFile = Path.Combine(appdata, "Microsoft", "UserSecrets", userSecretsId, "secrets.json"); 257 | 258 | ReadOptionsFromJsonFile(secretsFile, webApp); 259 | } 260 | 261 | private static void ReadOptionsFromJsonFile(string path, WebAppConfig webApp) 262 | { 263 | if (!File.Exists(path)) return; 264 | 265 | var json = File.ReadAllText(path); 266 | var appSettings = JsonConvert.DeserializeAnonymousType(json, 267 | new { IsEncrypted = false, Values = new Dictionary() }); 268 | 269 | if (appSettings.Values != null && appSettings.Values.TryGetValue(NgrokSubdomainSettingName, out var subdomain)) 270 | { 271 | webApp.SubDomain = subdomain; 272 | } 273 | } 274 | 275 | private static void DebugWriteProp(Property prop) 276 | { 277 | ThreadHelper.ThrowIfNotOnUIThread(); 278 | try 279 | { 280 | Debug.WriteLine($"{prop.Name} = {prop.Value}"); 281 | } 282 | catch 283 | { 284 | // ignored 285 | } 286 | } 287 | 288 | private IEnumerable GetSolutionProjects() 289 | { 290 | ThreadHelper.ThrowIfNotOnUIThread(); 291 | var solution = (ServiceProvider.GetService(typeof(SDTE)) as DTE)?.Solution; 292 | return solution == null ? null : ProcessProjects(solution.Projects.Cast()); 293 | } 294 | 295 | private static IEnumerable ProcessProjects(IEnumerable projects) 296 | { 297 | ThreadHelper.ThrowIfNotOnUIThread(); 298 | var newProjectsList = new List(); 299 | foreach (var p in projects) 300 | { 301 | 302 | if (p.Kind == ProjectKinds.vsProjectKindSolutionFolder) 303 | { 304 | newProjectsList.AddRange(ProcessProjects(GetSolutionFolderProjects(p))); 305 | } 306 | else 307 | { 308 | newProjectsList.Add(p); 309 | } 310 | } 311 | 312 | return newProjectsList; 313 | } 314 | 315 | private static IEnumerable GetSolutionFolderProjects(Project project) 316 | { 317 | ThreadHelper.ThrowIfNotOnUIThread(); 318 | return project.ProjectItems.Cast() 319 | .Select(item => item.SubProject) 320 | .Where(subProject => subProject != null) 321 | .ToList(); 322 | } 323 | } 324 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/StartTunnelPackage.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.InteropServices; 3 | using Microsoft.VisualStudio.Shell; 4 | 5 | namespace NgrokExtensions 6 | { 7 | /// 8 | /// This is the class that implements the package exposed by this assembly. 9 | /// 10 | /// 11 | /// 12 | /// The minimum requirement for a class to be considered a valid package for Visual Studio 13 | /// is to implement the IVsPackage interface and register itself with the shell. 14 | /// This package uses the helper classes defined inside the Managed Package Framework (MPF) 15 | /// to do it: it derives from the Package class that provides the implementation of the 16 | /// IVsPackage interface and uses the registration attributes defined in the framework to 17 | /// register itself and its components with the shell. These attributes tell the pkgdef creation 18 | /// utility what data to put into .pkgdef file. 19 | /// 20 | /// 21 | /// To get loaded into VS, the package must be referred by <Asset Type="Microsoft.VisualStudio.VsPackage" ...> in .vsixmanifest file. 22 | /// 23 | /// 24 | [PackageRegistration(UseManagedResourcesOnly = true)] 25 | [InstalledProductRegistration("#110", "#112", "1.0", IconResourceID = 400)] // Info on this package for Help/About 26 | [ProvideMenuResource("Menus.ctmenu", 1)] 27 | [Guid(StartTunnelPackage.PackageGuidString)] 28 | [ProvideOptionPage(typeof(OptionsPageGrid), "ngrok", "Options", 0, 0, true)] 29 | [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "pkgdef, VS and vsixmanifest are valid VS terms")] 30 | public sealed class StartTunnelPackage : Package 31 | { 32 | /// 33 | /// StartTunnelPackage GUID string. 34 | /// 35 | public const string PackageGuidString = "9f845cfc-84ef-4aac-9826-d46a83744fb4"; 36 | 37 | public string ExecutablePath 38 | { 39 | get 40 | { 41 | OptionsPageGrid page = (OptionsPageGrid)this.GetDialogPage(typeof(OptionsPageGrid)); 42 | return page.ExecutablePath; 43 | } 44 | } 45 | 46 | /// 47 | /// Initializes a new instance of the class. 48 | /// 49 | public StartTunnelPackage() 50 | { 51 | // Inside this method you can place any initialization code that does not require 52 | // any Visual Studio service because at this point the package object is created but 53 | // not sited yet inside Visual Studio environment. The place to do all the other 54 | // initialization is the Initialize method. 55 | } 56 | 57 | #region Package Members 58 | 59 | /// 60 | /// Initialization of the package; this method is called right after the package is sited, so this is the place 61 | /// where you can put all the initialization code that rely on services provided by VisualStudio. 62 | /// 63 | protected override void Initialize() 64 | { 65 | StartTunnel.Initialize(this); 66 | base.Initialize(); 67 | } 68 | 69 | #endregion 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/StartTunnelPackage.vsct: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 55 | 62 | 63 | 64 | 65 | 66 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/StartTunnelPackage1.cs: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // This file was generated by VSIX Synchronizer 4 | // 5 | // ------------------------------------------------------------------------------ 6 | namespace NgrokExtensions 7 | { 8 | using System; 9 | 10 | /// 11 | /// Helper class that exposes all GUIDs used across VS Package. 12 | /// 13 | internal sealed partial class PackageGuids 14 | { 15 | public const string guidStartTunnelPackageString = "9f845cfc-84ef-4aac-9826-d46a83744fb4"; 16 | public static Guid guidStartTunnelPackage = new Guid(guidStartTunnelPackageString); 17 | 18 | public const string guidStartTunnelPackageCmdSetString = "30d1a36d-a03a-456d-b639-f28b9b23e161"; 19 | public static Guid guidStartTunnelPackageCmdSet = new Guid(guidStartTunnelPackageCmdSetString); 20 | 21 | public const string guidImagesString = "53461b84-0d32-4e9f-bd34-4ddb67e572a7"; 22 | public static Guid guidImages = new Guid(guidImagesString); 23 | } 24 | /// 25 | /// Helper class that encapsulates all CommandIDs uses across VS Package. 26 | /// 27 | internal sealed partial class PackageIds 28 | { 29 | public const int MyMenuGroup = 0x1020; 30 | public const int StartTunnelId = 0x0100; 31 | public const int bmpPic1 = 0x0001; 32 | public const int bmpPic2 = 0x0002; 33 | public const int bmpPicSearch = 0x0003; 34 | public const int bmpPicX = 0x0004; 35 | public const int bmpPicArrows = 0x0005; 36 | public const int bmpPicStrikethrough = 0x0006; 37 | } 38 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/StartTunnelPackage2.cs: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // This file was generated by VSIX Synchronizer 4 | // 5 | // ------------------------------------------------------------------------------ 6 | namespace NgrokExtensions 7 | { 8 | using System; 9 | 10 | /// 11 | /// Helper class that exposes all GUIDs used across VS Package. 12 | /// 13 | internal sealed partial class PackageGuids 14 | { 15 | public const string guidStartTunnelPackageString = "9f845cfc-84ef-4aac-9826-d46a83744fb4"; 16 | public static Guid guidStartTunnelPackage = new Guid(guidStartTunnelPackageString); 17 | 18 | public const string guidStartTunnelPackageCmdSetString = "30d1a36d-a03a-456d-b639-f28b9b23e161"; 19 | public static Guid guidStartTunnelPackageCmdSet = new Guid(guidStartTunnelPackageCmdSetString); 20 | 21 | public const string guidImagesString = "53461b84-0d32-4e9f-bd34-4ddb67e572a7"; 22 | public static Guid guidImages = new Guid(guidImagesString); 23 | } 24 | /// 25 | /// Helper class that encapsulates all CommandIDs uses across VS Package. 26 | /// 27 | internal sealed partial class PackageIds 28 | { 29 | public const int MyMenuGroup = 0x1020; 30 | public const int StartTunnelId = 0x0100; 31 | public const int bmpPic1 = 0x0001; 32 | public const int bmpPic2 = 0x0002; 33 | public const int bmpPicSearch = 0x0003; 34 | public const int bmpPicX = 0x0004; 35 | public const int bmpPicArrows = 0x0005; 36 | public const int bmpPicStrikethrough = 0x0006; 37 | } 38 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/VSPackage.resx: -------------------------------------------------------------------------------- 1 |  2 | 12 | 13 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | text/microsoft-resx 120 | 121 | 122 | 2.0 123 | 124 | 125 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 126 | 127 | 128 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 129 | 130 | 131 | 132 | StartTunnel Extension 133 | 134 | 135 | StartTunnel Visual Studio Extension Detailed Info 136 | 137 | 138 | Resources\tunnel.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a 139 | 140 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Shared/WebAppConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace NgrokExtensions 4 | { 5 | public class WebAppConfig 6 | { 7 | private static readonly Regex HttpsPattern = new Regex(@"^https://[^/]+"); 8 | private static readonly Regex NumberPattern = new Regex(@"\d+"); 9 | 10 | public bool IsValid 11 | { 12 | get 13 | { 14 | return NgrokAddress != null; 15 | } 16 | } 17 | 18 | public string NgrokAddress { get; } 19 | public string SubDomain { get; set; } 20 | public string PublicUrl { get; set; } 21 | 22 | public WebAppConfig(string settingValue) 23 | { 24 | NgrokAddress = ParseNgrokAddress(settingValue); 25 | } 26 | 27 | private string ParseNgrokAddress(string settingValue) 28 | { 29 | var match = HttpsPattern.Match(settingValue); 30 | if (match.Success) return match.Value; 31 | 32 | match = NumberPattern.Match(settingValue); 33 | if (match.Success) return $"localhost:{match.Value}"; 34 | 35 | return null; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/FakeNgrokProcess.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace NgrokExtensions.Test 4 | { 5 | public class FakeNgrokProcess : NgrokProcess 6 | { 7 | private readonly string _stdout; 8 | 9 | public FakeNgrokProcess(string exePath, string stdout) : base(exePath) 10 | { 11 | _stdout = stdout; 12 | } 13 | 14 | public int StartCount { get; set; } = 0; 15 | public ProcessStartInfo LastProcessStartInfo { get; set; } 16 | 17 | protected override void Start(ProcessStartInfo pi) 18 | { 19 | StartCount++; 20 | LastProcessStartInfo = pi; 21 | } 22 | 23 | protected override string GetStandardOutput() 24 | { 25 | return _stdout; 26 | } 27 | 28 | protected override void WaitForExit() 29 | { 30 | // nothing to wait for in testing 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/IErrorDisplayFunc.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace NgrokExtensions.Test 4 | { 5 | public interface IErrorDisplayFunc 6 | { 7 | Task ShowErrorAsync(string msg); 8 | } 9 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/NgrokExtensions.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Debug 5 | AnyCPU 6 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023} 7 | Library 8 | Properties 9 | NgrokExtensions.Test 10 | NgrokExtensions.Test 11 | v4.7.2 12 | 512 13 | {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 14 | 10.0 15 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 16 | $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages 17 | False 18 | UnitTest 19 | 20 | true 21 | true 22 | 23 | 24 | true 25 | full 26 | false 27 | bin\Debug\ 28 | DEBUG;TRACE 29 | prompt 30 | 4 31 | 32 | 33 | pdbonly 34 | true 35 | bin\Release\ 36 | TRACE 37 | prompt 38 | 4 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 4.4.1 68 | 69 | 70 | 4.16.1 71 | 72 | 73 | 13.0.1 74 | 75 | 76 | 6.0.0 77 | 78 | 79 | 4.3.4 80 | 81 | 82 | 4.5.4 83 | 84 | 85 | 6.0.0 86 | 87 | 88 | 89 | 90 | {8fcaf75b-bcdf-415b-aae2-ecebaf53dcd1} 91 | NgrokExtensions.2019 92 | 93 | 94 | 95 | 96 | 97 | 98 | False 99 | 100 | 101 | False 102 | 103 | 104 | False 105 | 106 | 107 | False 108 | 109 | 110 | 111 | 112 | 113 | 114 | 121 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/NgrokInstallerTest.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Text.RegularExpressions; 7 | using System.Threading.Tasks; 8 | using Microsoft.VisualStudio.TestTools.UnitTesting; 9 | using RichardSzalay.MockHttp; 10 | 11 | namespace NgrokExtensions.Test 12 | { 13 | [TestClass] 14 | public class NgrokInstallerTest 15 | { 16 | private readonly HttpClient _mockHttpClient; 17 | private MockHttpMessageHandler _mockHttpMessageHandler; 18 | 19 | public NgrokInstallerTest() 20 | { 21 | _mockHttpMessageHandler = new MockHttpMessageHandler(); 22 | _mockHttpMessageHandler.When("https://ngrok.com/download").Respond("text/html", TestResponseContent); 23 | 24 | var stream = new MemoryStream(SampleZip); 25 | _mockHttpMessageHandler.When("https://fakedomain.io/ngrok-stable-windows-amd64.zip").Respond("application/zip", stream); 26 | _mockHttpMessageHandler.When("https://fakedomain.io/ngrok-stable-windows-386.zip").Respond("application/zip", stream); 27 | 28 | _mockHttpClient = _mockHttpMessageHandler.ToHttpClient(); 29 | } 30 | 31 | [TestMethod] 32 | public async Task TestGetNgrokDownloadUrlAsync() 33 | { 34 | var installer = new NgrokInstaller(_mockHttpClient, true); 35 | var url = await installer.GetNgrokDownloadUrlAsync(); 36 | Assert.AreEqual("https://fakedomain.io/ngrok-stable-windows-amd64.zip", url); 37 | } 38 | 39 | [TestMethod] 40 | public async Task TestGetNgrokDownloadUrl32BitAsync() 41 | { 42 | var installer = new NgrokInstaller(_mockHttpClient, false); 43 | var url = await installer.GetNgrokDownloadUrlAsync(); 44 | Assert.AreEqual("https://fakedomain.io/ngrok-stable-windows-386.zip", url); 45 | } 46 | 47 | [TestMethod] 48 | [ExpectedException(typeof(NgrokDownloadException))] 49 | public async Task TestGetNgrokDownloadUrlHttpErrorAsync() 50 | { 51 | _mockHttpMessageHandler = new MockHttpMessageHandler(); 52 | _mockHttpMessageHandler.When("https://ngrok.com/download") 53 | .Respond(x => new HttpResponseMessage(HttpStatusCode.NotFound)); 54 | var installer = new NgrokInstaller(_mockHttpMessageHandler.ToHttpClient(), false); 55 | await installer.GetNgrokDownloadUrlAsync(); 56 | } 57 | 58 | [TestMethod] 59 | [ExpectedException(typeof(NgrokDownloadException))] 60 | public async Task TestGetNgrokDownloadUrlTextNotFoundAsync() 61 | { 62 | _mockHttpMessageHandler = new MockHttpMessageHandler(); 63 | _mockHttpMessageHandler.When("https://ngrok.com/download") 64 | .Respond("text/html", "

some html without expected download links

"); 65 | var installer = new NgrokInstaller(_mockHttpMessageHandler.ToHttpClient(), false); 66 | await installer.GetNgrokDownloadUrlAsync(); 67 | } 68 | 69 | [TestMethod] 70 | public async Task TestDownloadNgrokAsync() 71 | { 72 | var installer = new NgrokInstaller(_mockHttpClient, true); 73 | var stream = await installer.DownloadNgrokAsync(); 74 | var buffer = new byte[SampleZip.Length]; 75 | await stream.ReadAsync(buffer, 0, SampleZip.Length); 76 | Assert.IsTrue(buffer.SequenceEqual(SampleZip)); 77 | } 78 | 79 | [TestMethod] 80 | [ExpectedException(typeof(NgrokDownloadException))] 81 | public async Task TestDownloadNgrokFailedAsync() 82 | { 83 | _mockHttpMessageHandler = new MockHttpMessageHandler(); 84 | _mockHttpMessageHandler.When("https://fakedomain.io/ngrok.zip") 85 | .Respond(x => new HttpResponseMessage(HttpStatusCode.NotFound)); 86 | var installer = new NgrokInstaller(_mockHttpMessageHandler.ToHttpClient(), false); 87 | 88 | await installer.DownloadNgrokAsync("https://fakedomain.io/ngrok.zip"); 89 | } 90 | 91 | [TestMethod] 92 | public async Task TestInstallNgrokAsync() 93 | { 94 | var installer = new NgrokInstaller(_mockHttpClient, true); 95 | var path = await installer.InstallNgrokAsync(); 96 | Assert.IsTrue(Regex.IsMatch(path, @"^.*\\ngrok\.exe$")); 97 | Assert.IsTrue(File.Exists(path)); 98 | File.Delete(path); 99 | } 100 | 101 | private const string TestResponseContent = @"
102 |
103 |

Download & setup ngrok

104 |

105 | Get started with ngrok in just a few seconds. 106 |

107 |
108 | 109 |
    110 |
  1. 111 |

    Download ngrok

    112 |

    113 | First, download the ngrok client, a single binary with zero run-time dependencies. 114 |

    115 | "; 151 | 152 | // ZIP file with a single "ngrok.exe" entry 153 | private static readonly byte[] SampleZip = { 0x50, 0x4B, 0x03, 0x04, 0x0A, 0, 0, 0, 0, 0, 0xED, 0x6E, 0xC8, 0x4A, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x09, 0, 0, 0, 0x6E, 0x67, 0x72, 0x6F, 0x6B, 0x2E, 0x65, 0x78, 0x65, 0x50, 0x4B, 0x01, 0x02, 0x3F, 0, 0x0A, 0, 0, 0, 0, 0, 0xED, 0x6E, 0xC8, 0x4A, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x09, 0, 0x24, 0, 0, 0, 0, 0, 0, 0, 0x20, 0, 0, 0, 0, 0, 0, 0, 0x6E, 0x67, 0x72, 0x6F, 0x6B, 0x2E, 0x65, 0x78, 0x65, 0x0A, 0, 0x20, 0, 0, 0, 0, 0, 0x01, 0, 0x18, 0, 0xF2, 0xF2, 0x5B, 0x8D, 0x99, 0xE0, 0xD2, 0x01, 0xF2, 0xF2, 0x5B, 0x8D, 0x99, 0xE0, 0xD2, 0x01, 0xF2, 0xF2, 0x5B, 0x8D, 0x99, 0xE0, 0xD2, 0x01, 0x50, 0x4B, 0x05, 0x06, 0, 0, 0, 0, 0x01, 0, 0x01, 0, 0x5B, 0, 0, 0, 0x27, 0, 0, 0, 0, 0 }; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/NgrokUtilsTest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using Microsoft.VisualStudio.TestTools.UnitTesting; 7 | using Moq; 8 | using Newtonsoft.Json; 9 | using RichardSzalay.MockHttp; 10 | 11 | namespace NgrokExtensions.Test 12 | { 13 | [TestClass] 14 | public class NgrokUtilsTest 15 | { 16 | private MockHttpMessageHandler _mockHttp; 17 | private HttpClient _client; 18 | private Mock _mockErrorDisplay; 19 | private FakeNgrokProcess _ngrokProcess; 20 | private NgrokUtils _utils; 21 | private Dictionary _webApps; 22 | private NgrokTunnelApiRequest _expectedRequest; 23 | private NgrokTunnelsApiResponse _emptyTunnelsResponse; 24 | private int _expectedProcessCount; 25 | private string _tempFile; 26 | 27 | [TestInitialize] 28 | public void Initialize() 29 | { 30 | _expectedProcessCount = 0; 31 | _tempFile = Path.GetTempFileName(); 32 | _mockHttp = new MockHttpMessageHandler(); 33 | _client = new HttpClient(_mockHttp); 34 | 35 | _webApps = new Dictionary 36 | { 37 | { 38 | "fakeApp", 39 | new WebAppConfig("1234") 40 | { 41 | SubDomain = "fake-app" 42 | } 43 | } 44 | }; 45 | 46 | _mockErrorDisplay = new Mock(); 47 | _mockErrorDisplay.Setup(x => x.ShowErrorAsync(It.IsAny())) 48 | .Returns(Task.FromResult(0)) 49 | .Verifiable("Error display not called."); 50 | 51 | InitializeUtils("ngrok version 2.3.34\r\n"); 52 | 53 | _emptyTunnelsResponse = new NgrokTunnelsApiResponse 54 | { 55 | tunnels = new Tunnel[0], 56 | uri = "" 57 | }; 58 | 59 | _expectedRequest = new NgrokTunnelApiRequest 60 | { 61 | addr = "localhost:1234", 62 | host_header = "localhost:1234", 63 | name = "fakeApp", 64 | proto = "http", 65 | subdomain = "fake-app" 66 | }; 67 | } 68 | 69 | private void InitializeUtils(string stdout) 70 | { 71 | _ngrokProcess = new FakeNgrokProcess(_tempFile, stdout); 72 | _utils = new NgrokUtils(_webApps, _tempFile, _mockErrorDisplay.Object.ShowErrorAsync, _client, _ngrokProcess); 73 | } 74 | 75 | [TestCleanup] 76 | public void Cleanup() 77 | { 78 | if(File.Exists(_tempFile)) 79 | { 80 | File.Delete(_tempFile); 81 | } 82 | Assert.AreEqual(_expectedProcessCount, _ngrokProcess.StartCount); 83 | _mockHttp.VerifyNoOutstandingExpectation(); 84 | } 85 | 86 | [TestMethod] 87 | public async Task TestStartTunnelAsync() 88 | { 89 | _mockHttp.Expect("http://localhost:4040/api/tunnels") 90 | .Respond("application/json", JsonConvert.SerializeObject(_emptyTunnelsResponse)); 91 | 92 | _mockHttp.Expect(HttpMethod.Post, "http://localhost:4040/api/tunnels") 93 | .WithContent(JsonConvert.SerializeObject(_expectedRequest)) 94 | .Respond("application/json", "{}"); 95 | 96 | await _utils.StartTunnelsAsync(); 97 | } 98 | 99 | [TestMethod] 100 | public async Task TestStartTunnelNotRunningAsync() 101 | { 102 | _mockHttp.Expect("http://localhost:4040/api/tunnels") 103 | .Respond(HttpStatusCode.BadGateway); 104 | 105 | _mockHttp.Expect("http://localhost:4040/api/tunnels") 106 | .Respond("application/json", JsonConvert.SerializeObject(_emptyTunnelsResponse)); 107 | 108 | _mockHttp.Expect(HttpMethod.Post, "http://localhost:4040/api/tunnels") 109 | .WithContent(JsonConvert.SerializeObject(_expectedRequest)) 110 | .Respond("application/json", "{}"); 111 | 112 | await _utils.StartTunnelsAsync(); 113 | 114 | _expectedProcessCount = 1; 115 | } 116 | 117 | [TestMethod] 118 | public async Task TestStartTunnelExistingAsync() 119 | { 120 | var tunnels = new NgrokTunnelsApiResponse 121 | { 122 | tunnels = new[] 123 | { 124 | new Tunnel 125 | { 126 | config = new Config 127 | { 128 | addr = "localhost:1234", 129 | inspect = true 130 | }, 131 | name = "fakeApp", 132 | proto = "https", 133 | public_url = "https://fake-app.ngrok.io" 134 | }, 135 | new Tunnel 136 | { 137 | config = new Config 138 | { 139 | addr = "localhost:1234", 140 | inspect = true 141 | }, 142 | name = "fakeApp", 143 | proto = "http", 144 | public_url = "http://fake-app.ngrok.io" 145 | } 146 | }, 147 | uri = "" 148 | }; 149 | 150 | _mockHttp.Expect("http://localhost:4040/api/tunnels") 151 | .Respond("application/json", JsonConvert.SerializeObject(tunnels)); 152 | 153 | await _utils.StartTunnelsAsync(); 154 | } 155 | 156 | [TestMethod] 157 | public void TestNgrokIsInstalled() 158 | { 159 | Assert.AreEqual(true, _utils.NgrokIsInstalled()); 160 | _expectedProcessCount = 1; 161 | } 162 | 163 | [TestMethod] 164 | public void TestNgrokOldVersion() 165 | { 166 | InitializeUtils("ngrok version 2.3.32\r\n"); 167 | Assert.AreEqual(false, _utils.NgrokIsInstalled()); 168 | _expectedProcessCount = 1; 169 | } 170 | 171 | [TestMethod] 172 | public void TestNgrokNewerVersion() 173 | { 174 | InitializeUtils("ngrok version 3.0.1\r\n"); 175 | Assert.AreEqual(true, _utils.NgrokIsInstalled()); 176 | _expectedProcessCount = 1; 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.InteropServices; 3 | 4 | // General Information about an assembly is controlled through the following 5 | // set of attributes. Change these attribute values to modify the information 6 | // associated with an assembly. 7 | [assembly: AssemblyTitle("NgrokExtensions.Test")] 8 | [assembly: AssemblyDescription("")] 9 | [assembly: AssemblyConfiguration("")] 10 | [assembly: AssemblyCompany("")] 11 | [assembly: AssemblyProduct("NgrokExtensions.Test")] 12 | [assembly: AssemblyCopyright("Copyright 2023 David Prothero (MIT License)")] 13 | [assembly: AssemblyTrademark("")] 14 | [assembly: AssemblyCulture("")] 15 | 16 | // Setting ComVisible to false makes the types in this assembly not visible 17 | // to COM components. If you need to access a type in this assembly from 18 | // COM, set the ComVisible attribute to true on that type. 19 | [assembly: ComVisible(false)] 20 | 21 | // The following GUID is for the ID of the typelib if this project is exposed to COM 22 | [assembly: Guid("a409f2f2-6e3a-45dd-8c7e-8dd8569e9023")] 23 | 24 | // Version information for an assembly consists of the following four values: 25 | // 26 | // Major Version 27 | // Minor Version 28 | // Build Number 29 | // Revision 30 | // 31 | // You can specify all the values or you can default the Build and Revision Numbers 32 | // by using the '*' as shown below: 33 | // [assembly: AssemblyVersion("1.0.*")] 34 | [assembly: AssemblyVersion("0.9.14.0")] 35 | [assembly: AssemblyFileVersion("0.9.14.0")] 36 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions.Test/WebAppConfigTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace NgrokExtensions.Test 4 | { 5 | /// 6 | /// Summary description for WebAppConfigTest 7 | /// 8 | [TestClass] 9 | public class WebAppConfigTest 10 | { 11 | [TestMethod] 12 | public void TestUrlNoHttps() 13 | { 14 | var webApp = new WebAppConfig("http://localhost:1234/"); 15 | Assert.AreEqual("localhost:1234", webApp.NgrokAddress); 16 | } 17 | 18 | [TestMethod] 19 | public void TestPortNumberOnly() 20 | { 21 | var webApp = new WebAppConfig("1234"); 22 | Assert.AreEqual("localhost:1234", webApp.NgrokAddress); 23 | } 24 | 25 | [TestMethod] 26 | public void TestUrlWithHttps() 27 | { 28 | var webApp = new WebAppConfig("https://localhost:1234/"); 29 | Assert.AreEqual("https://localhost:1234", webApp.NgrokAddress); 30 | } 31 | 32 | [TestMethod] 33 | public void TestUrlWithHttpsAndLongPath() 34 | { 35 | var webApp = new WebAppConfig("https://localhost:1234/foo/bar"); 36 | Assert.AreEqual("https://localhost:1234", webApp.NgrokAddress); 37 | } 38 | 39 | [TestMethod] 40 | public void TestUrlWithHttpsAndNoPath() 41 | { 42 | var webApp = new WebAppConfig("https://localhost:1234"); 43 | Assert.AreEqual("https://localhost:1234", webApp.NgrokAddress); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/Key.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions/Key.snk -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 David Prothero 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 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokErrorApiResult.cs: -------------------------------------------------------------------------------- 1 | namespace NgrokExtensions 2 | { 3 | public class NgrokErrorApiResult 4 | { 5 | public int error_code { get; set; } 6 | public int status_code { get; set; } 7 | public string msg { get; set; } 8 | public Details details { get; set; } 9 | } 10 | 11 | public class Details 12 | { 13 | public string err { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokExtensions.2019.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 15.0 5 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 6 | 7 | 8 | true 9 | 10 | 11 | 12 | 13 | 14.0 14 | publish\ 15 | true 16 | Disk 17 | false 18 | Foreground 19 | 7 20 | Days 21 | false 22 | false 23 | true 24 | 0 25 | 1.0.0.%2a 26 | false 27 | false 28 | true 29 | 30 | 31 | 32 | true 33 | 34 | 35 | Key.snk 36 | 37 | 38 | 39 | Debug 40 | AnyCPU 41 | 2.0 42 | {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 43 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1} 44 | Library 45 | Properties 46 | NgrokExtensions 47 | NgrokExtensions 48 | v4.7.2 49 | true 50 | true 51 | true 52 | true 53 | true 54 | false 55 | 56 | 57 | true 58 | full 59 | false 60 | bin\Debug\ 61 | DEBUG;TRACE 62 | prompt 63 | 4 64 | Program 65 | $(DevEnvDir)devenv.exe 66 | /rootsuffix Exp 67 | False 68 | 69 | 70 | 71 | 72 | pdbonly 73 | true 74 | bin\Release\ 75 | TRACE 76 | prompt 77 | 4 78 | Program 79 | $(VS140COMNTOOLS)\..\IDE\devenv.exe 80 | /rootsuffix Exp 81 | 82 | 83 | 84 | NgrokErrorApiResult.cs 85 | 86 | 87 | NgrokInstaller.cs 88 | 89 | 90 | NgrokProcess.cs 91 | 92 | 93 | NgrokTunnelApiRequest.cs 94 | 95 | 96 | NgrokTunnelsApiResponse.cs 97 | 98 | 99 | NgrokUtils.cs 100 | 101 | 102 | OptionsPageGrid.cs 103 | Component 104 | 105 | 106 | StartTunnel.cs 107 | 108 | 109 | StartTunnelPackage.cs 110 | 111 | 112 | WebAppConfig.cs 113 | 114 | 115 | 116 | 117 | 118 | Resources\PreviewImage.png 119 | Always 120 | true 121 | 122 | 123 | Resources\StartTunnel.png 124 | 125 | 126 | Resources\tunnel.ico 127 | Always 128 | true 129 | 130 | 131 | Menus.ctmenu 132 | 133 | 134 | Always 135 | true 136 | 137 | 138 | 139 | Designer 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | False 157 | Microsoft .NET Framework 4.5.2 %28x86 and x64%29 158 | true 159 | 160 | 161 | False 162 | .NET Framework 3.5 SP1 163 | false 164 | 165 | 166 | 167 | 168 | VSPackage.resx 169 | 170 | 171 | 172 | 173 | 5.2.7 174 | 175 | 176 | 15.0.1 177 | 178 | 179 | 8.0.3 180 | 181 | 182 | 6.0.0 183 | 184 | 185 | 186 | 187 | 194 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokInstaller.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Net.Http; 5 | using System.Text.RegularExpressions; 6 | using System.Threading.Tasks; 7 | 8 | namespace NgrokExtensions 9 | { 10 | public class NgrokDownloadException : Exception 11 | { 12 | public NgrokDownloadException(string message) : base(message) 13 | { 14 | 15 | } 16 | } 17 | 18 | public class NgrokInstaller 19 | { 20 | private readonly HttpClient _httpClient; 21 | private readonly bool _is64Bit; 22 | 23 | public NgrokInstaller() 24 | { 25 | _httpClient = new HttpClient(); 26 | _is64Bit = Environment.Is64BitOperatingSystem; 27 | } 28 | 29 | public NgrokInstaller(HttpClient httpClient, bool is64Bit) 30 | { 31 | _httpClient = httpClient; 32 | _is64Bit = is64Bit; 33 | } 34 | 35 | public async Task GetNgrokDownloadUrl() 36 | { 37 | var response = await _httpClient.GetAsync("https://ngrok.com/download"); 38 | 39 | if (!response.IsSuccessStatusCode) 40 | { 41 | throw new NgrokDownloadException($"Error retrieving ngrok download page. ({response.StatusCode})"); 42 | } 43 | 44 | var html = await response.Content.ReadAsStringAsync(); 45 | 46 | var downloadLinkId = _is64Bit ? "dl-windows-amd64" : "dl-windows-386"; 47 | var pattern = @"id=""" + downloadLinkId + 48 | @"""(?:.|\s)*?[^>]+?href=""(http[s]?:\/\/[^""]*?)"""; 49 | 50 | var match = Regex.Match(html, pattern); 51 | 52 | if (!match.Success) 53 | { 54 | throw new NgrokDownloadException("Could not find ngrok download URL."); 55 | } 56 | 57 | return match.Groups[1].Value.Replace("&", "&"); 58 | } 59 | 60 | public async Task DownloadNgrok(string url = null) 61 | { 62 | if (string.IsNullOrEmpty(url)) 63 | { 64 | url = await GetNgrokDownloadUrl(); 65 | } 66 | 67 | var response = await _httpClient.GetAsync(url); 68 | 69 | if (!response.IsSuccessStatusCode) 70 | { 71 | throw new NgrokDownloadException($"Error trying to download {url}. ({response.StatusCode})"); 72 | } 73 | 74 | return await response.Content.ReadAsStreamAsync(); 75 | } 76 | 77 | public async Task InstallNgrok() 78 | { 79 | var zip = new ZipArchive(await DownloadNgrok(), ZipArchiveMode.Read); 80 | var exeEntry = zip.GetEntry("ngrok.exe"); 81 | using (var exeData = exeEntry.Open()) 82 | { 83 | var ngrokPath = Path.GetFullPath(".\\ngrok.exe"); 84 | using (var exeFile = File.Create(ngrokPath)) 85 | { 86 | await exeData.CopyToAsync(exeFile); 87 | return ngrokPath; 88 | } 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokProcess.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace NgrokExtensions 8 | { 9 | public class NgrokProcess 10 | { 11 | private static readonly Regex VersionPattern = new Regex(@"\d+\.\d+\.\d+"); 12 | private readonly string _exePath; 13 | private Process _osProcess; 14 | 15 | public NgrokProcess(string exePath) 16 | { 17 | _exePath = exePath; 18 | } 19 | 20 | public string GetNgrokVersion() 21 | { 22 | StartNgrokProcess("--version", false); 23 | var version = GetStandardOutput(); 24 | WaitForExit(); 25 | 26 | var match = VersionPattern.Match(version); 27 | return match.Success ? match.Value : null; 28 | } 29 | 30 | public void StartNgrokProcess(string args = "start --none", bool showWindow = true) 31 | { 32 | var path = GetNgrokPath(); 33 | 34 | var pi = new ProcessStartInfo(path, args) 35 | { 36 | CreateNoWindow = !showWindow, 37 | WindowStyle = showWindow ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden, 38 | RedirectStandardOutput = !showWindow, 39 | UseShellExecute = showWindow 40 | }; 41 | 42 | Start(pi); 43 | } 44 | 45 | private string GetNgrokPath() 46 | { 47 | var path = "ngrok.exe"; 48 | 49 | if (!string.IsNullOrWhiteSpace(_exePath) && File.Exists(_exePath)) 50 | { 51 | path = _exePath; 52 | } 53 | 54 | return path; 55 | } 56 | 57 | protected virtual void Start(ProcessStartInfo pi) 58 | { 59 | _osProcess = Process.Start(pi); 60 | } 61 | 62 | protected virtual string GetStandardOutput() 63 | { 64 | return _osProcess.StandardOutput.ReadToEnd(); 65 | } 66 | 67 | protected virtual void WaitForExit() 68 | { 69 | _osProcess.WaitForExit(); 70 | } 71 | 72 | public bool IsInstalled() 73 | { 74 | var fileName = GetNgrokPath(); 75 | 76 | if (File.Exists(fileName)) 77 | return true; 78 | 79 | var values = Environment.GetEnvironmentVariable("PATH") ?? ""; 80 | return values.Split(Path.PathSeparator) 81 | .Select(path => Path.Combine(path, fileName)) 82 | .Any(File.Exists); 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokTunnelApiRequest.cs: -------------------------------------------------------------------------------- 1 | namespace NgrokExtensions 2 | { 3 | public class NgrokTunnelApiRequest 4 | { 5 | public string name { get; set; } 6 | public string addr { get; set; } 7 | public string proto { get; set; } 8 | public string subdomain { get; set; } 9 | public string host_header { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokTunnelsApiResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NgrokExtensions 2 | { 3 | 4 | public class NgrokTunnelsApiResponse 5 | { 6 | public Tunnel[] tunnels { get; set; } 7 | public string uri { get; set; } 8 | } 9 | 10 | public class Tunnel 11 | { 12 | public string name { get; set; } 13 | public string uri { get; set; } 14 | public string public_url { get; set; } 15 | public string proto { get; set; } 16 | public Config config { get; set; } 17 | public Metrics metrics { get; set; } 18 | } 19 | 20 | public class Config 21 | { 22 | public string addr { get; set; } 23 | public bool inspect { get; set; } 24 | } 25 | 26 | public class Metrics 27 | { 28 | public Conns conns { get; set; } 29 | public Http http { get; set; } 30 | } 31 | 32 | public class Conns 33 | { 34 | public int count { get; set; } 35 | public int gauge { get; set; } 36 | public decimal rate1 { get; set; } 37 | public decimal rate5 { get; set; } 38 | public decimal rate15 { get; set; } 39 | public decimal p50 { get; set; } 40 | public decimal p90 { get; set; } 41 | public decimal p95 { get; set; } 42 | public decimal p99 { get; set; } 43 | } 44 | 45 | public class Http 46 | { 47 | public int count { get; set; } 48 | public decimal rate1 { get; set; } 49 | public decimal rate5 { get; set; } 50 | public decimal rate15 { get; set; } 51 | public decimal p50 { get; set; } 52 | public decimal p90 { get; set; } 53 | public decimal p95 { get; set; } 54 | public decimal p99 { get; set; } 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/NgrokUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Net.Http; 8 | using System.Net.Http.Headers; 9 | using System.Threading.Tasks; 10 | using Newtonsoft.Json; 11 | using System.Net.Http.Json; 12 | 13 | namespace NgrokExtensions 14 | { 15 | public class NgrokUtils 16 | { 17 | public const string NgrokNotFoundMessage = "ngrok executable not found. Configure the path in the via the add-in options or add the location to your PATH."; 18 | private static readonly Version MinimumVersion = new Version("2.3.34"); 19 | private readonly Dictionary _webApps; 20 | private readonly Func _showErrorFunc; 21 | private readonly HttpClient _ngrokApi; 22 | private Tunnel[] _tunnels; 23 | private readonly NgrokProcess _ngrokProcess; 24 | 25 | public NgrokUtils(Dictionary webApps, string exePath, 26 | Func asyncShowErrorFunc, 27 | HttpClient client = null, NgrokProcess ngrokProcess = null) 28 | { 29 | _webApps = webApps; 30 | _ngrokProcess = ngrokProcess ?? new NgrokProcess(exePath); 31 | _showErrorFunc = asyncShowErrorFunc; 32 | _ngrokApi = client ?? new HttpClient(); 33 | _ngrokApi.BaseAddress = new Uri("http://localhost:4040"); 34 | _ngrokApi.DefaultRequestHeaders.Accept.Clear(); 35 | _ngrokApi.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 36 | } 37 | 38 | public bool NgrokIsInstalled() 39 | { 40 | if (!_ngrokProcess.IsInstalled()) return false; 41 | 42 | var versionString = _ngrokProcess.GetNgrokVersion(); 43 | if (versionString == null) return false; 44 | 45 | var version = new Version(versionString); 46 | return version.CompareTo(MinimumVersion) >= 0; 47 | } 48 | 49 | public async Task StartTunnelsAsync() 50 | { 51 | Exception uncaughtException = null; 52 | 53 | try 54 | { 55 | await DoStartTunnelsAsync(); 56 | } 57 | catch (FileNotFoundException) 58 | { 59 | await _showErrorFunc(NgrokNotFoundMessage); 60 | } 61 | catch (Win32Exception ex) 62 | { 63 | if (ex.ErrorCode.ToString("X") == "80004005") 64 | { 65 | await _showErrorFunc(NgrokNotFoundMessage); 66 | } 67 | else 68 | { 69 | uncaughtException = ex; 70 | } 71 | } 72 | catch (Exception ex) 73 | { 74 | uncaughtException = ex; 75 | } 76 | 77 | if (uncaughtException != null) 78 | { 79 | await _showErrorFunc($"Ran into a problem trying to start the ngrok tunnel(s): {uncaughtException}"); 80 | } 81 | } 82 | 83 | private async Task DoStartTunnelsAsync() 84 | { 85 | await StartNgrokAsync(); 86 | foreach (var projectName in _webApps.Keys) 87 | { 88 | await StartNgrokTunnelAsync(projectName, _webApps[projectName]); 89 | } 90 | } 91 | 92 | private async Task StartNgrokAsync(bool retry = false) 93 | { 94 | if (await CanGetTunnelList()) return; 95 | 96 | _ngrokProcess.StartNgrokProcess(); 97 | await Task.Delay(250); 98 | 99 | if (await CanGetTunnelList(retry:true)) return; 100 | await _showErrorFunc("Cannot start ngrok. Is it installed and in your PATH?"); 101 | } 102 | 103 | private async Task CanGetTunnelList(bool retry = false) 104 | { 105 | try 106 | { 107 | await GetTunnelList(); 108 | } 109 | catch 110 | { 111 | if (retry) throw; 112 | } 113 | return (_tunnels != null); 114 | } 115 | 116 | private async Task GetTunnelList() 117 | { 118 | var response = await _ngrokApi.GetAsync("/api/tunnels"); 119 | if (response.IsSuccessStatusCode) 120 | { 121 | var responseText = await response.Content.ReadAsStringAsync(); 122 | Debug.WriteLine($"responseText: '{responseText}'"); 123 | var apiResponse = JsonConvert.DeserializeObject(responseText); 124 | _tunnels = apiResponse.tunnels; 125 | } 126 | } 127 | 128 | private async Task StartNgrokTunnelAsync(string projectName, WebAppConfig config) 129 | { 130 | var addr = config.NgrokAddress; 131 | if (!TunnelAlreadyExists(addr)) 132 | { 133 | await CreateTunnelAsync(projectName, config, addr); 134 | } 135 | } 136 | 137 | private bool TunnelAlreadyExists(string addr) 138 | { 139 | return _tunnels.Any(t => t.config.addr == addr); 140 | } 141 | 142 | private string StripProtocol(string addr) 143 | { 144 | return addr.Replace("https://", ""); 145 | } 146 | 147 | private async Task CreateTunnelAsync(string projectName, WebAppConfig config, string addr, bool retry = false) 148 | { 149 | var request = new NgrokTunnelApiRequest 150 | { 151 | name = projectName, 152 | addr = addr, 153 | proto = "http", 154 | host_header = StripProtocol(addr) 155 | }; 156 | if (!string.IsNullOrEmpty(config.SubDomain)) 157 | { 158 | request.subdomain = config.SubDomain; 159 | } 160 | 161 | Debug.WriteLine($"request: '{JsonConvert.SerializeObject(request)}'"); 162 | var response = await _ngrokApi.PostAsJsonAsync("/api/tunnels", request); 163 | if (!response.IsSuccessStatusCode) 164 | { 165 | var errorText = await response.Content.ReadAsStringAsync(); 166 | Debug.WriteLine($"{response.StatusCode} errorText: '{errorText}'"); 167 | NgrokErrorApiResult error; 168 | 169 | try 170 | { 171 | error = JsonConvert.DeserializeObject(errorText); 172 | } 173 | catch(JsonReaderException) 174 | { 175 | error = null; 176 | } 177 | 178 | if (error != null) 179 | { 180 | await _showErrorFunc($"Could not create tunnel for {projectName} ({addr}): " + 181 | $"\n[{error.error_code}] {error.msg}" + 182 | $"\nDetails: {error.details.err.Replace("\\n", "\n")}"); 183 | } 184 | else 185 | { 186 | if (retry) 187 | { 188 | await _showErrorFunc($"Could not create tunnel for {projectName} ({addr}): " + 189 | $"\n{errorText}"); 190 | } 191 | else 192 | { 193 | await Task.Delay(1000); // wait for ngrok to spin up completely? 194 | await CreateTunnelAsync(projectName, config, addr, true); 195 | } 196 | } 197 | return; 198 | } 199 | 200 | var responseText = await response.Content.ReadAsStringAsync(); 201 | Debug.WriteLine($"responseText: '{responseText}'"); 202 | var tunnel = JsonConvert.DeserializeObject(responseText); 203 | config.PublicUrl = tunnel.public_url; 204 | Debug.WriteLine(config.PublicUrl); 205 | } 206 | } 207 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/OptionsPageGrid.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace NgrokExtensions 9 | { 10 | public class OptionsPageGrid : Microsoft.VisualStudio.Shell.DialogPage 11 | { 12 | private string executablePath = ""; 13 | 14 | [Category("ngrok")] 15 | [DisplayName("Executable Path")] 16 | [Description("Full path to the ngrok executable")] 17 | public string ExecutablePath 18 | { 19 | get { return executablePath; } 20 | set { executablePath = value; } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.InteropServices; 3 | 4 | // General Information about an assembly is controlled through the following 5 | // set of attributes. Change these attribute values to modify the information 6 | // associated with an assembly. 7 | [assembly: AssemblyTitle("NgrokExtensions")] 8 | [assembly: AssemblyDescription("Add ngrok to Visual Studio")] 9 | [assembly: AssemblyConfiguration("")] 10 | [assembly: AssemblyCompany("David Prothero")] 11 | [assembly: AssemblyProduct("NgrokExtensions")] 12 | [assembly: AssemblyCopyright("Copyright 2023 David Prothero (MIT License)")] 13 | [assembly: AssemblyTrademark("")] 14 | [assembly: AssemblyCulture("")] 15 | 16 | // Setting ComVisible to false makes the types in this assembly not visible 17 | // to COM components. If you need to access a type in this assembly from 18 | // COM, set the ComVisible attribute to true on that type. 19 | [assembly: ComVisible(false)] 20 | 21 | // Version information for an assembly consists of the following four values: 22 | // 23 | // Major Version 24 | // Minor Version 25 | // Build Number 26 | // Revision 27 | // 28 | // You can specify all the values or you can default the Build and Revision Numbers 29 | // by using the '*' as shown below: 30 | // [assembly: AssemblyVersion("1.0.*")] 31 | [assembly: AssemblyVersion("0.9.14.0")] 32 | [assembly: AssemblyFileVersion("0.9.14.0")] 33 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/Resources/PreviewImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions/Resources/PreviewImage.png -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/Resources/StartTunnel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions/Resources/StartTunnel.png -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/Resources/tunnel.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/NgrokExtensions/8417459e656b0e801883afdbd21c6d51bab3fab9/src/NgrokExtensionsSolution/NgrokExtensions/Resources/tunnel.ico -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/StartTunnel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.Design; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using System.Xml.Linq; 9 | using EnvDTE; 10 | using EnvDTE80; 11 | using Microsoft.VisualStudio.Shell; 12 | using Microsoft.VisualStudio.Shell.Interop; 13 | using Microsoft.VisualStudio.Threading; 14 | using Newtonsoft.Json; 15 | 16 | namespace NgrokExtensions 17 | { 18 | internal sealed class StartTunnel 19 | { 20 | private static readonly HashSet PortPropertyNames = new HashSet 21 | { 22 | "WebApplication.DevelopmentServerPort", 23 | "WebApplication.IISUrl", 24 | "WebApplication.CurrentDebugUrl", 25 | "WebApplication.NonSecureUrl", 26 | "WebApplication.BrowseURL", 27 | "NodejsPort", // Node.js project 28 | "FileName", // Azure functions if ends with '.funproj' 29 | "ProjectUrl" 30 | }; 31 | 32 | public const int CommandId = 0x0100; 33 | private const string NgrokSubdomainSettingName = "ngrok.subdomain"; 34 | public static readonly Guid CommandSet = new Guid("30d1a36d-a03a-456d-b639-f28b9b23e161"); 35 | private readonly Package _package; 36 | 37 | /// 38 | /// Initializes a new instance of the class. 39 | /// Adds our command handlers for menu (commands must exist in the command table file) 40 | /// 41 | /// Owner package, not null. 42 | private StartTunnel(Package package) 43 | { 44 | if (package == null) 45 | { 46 | throw new ArgumentNullException(nameof(package)); 47 | } 48 | _package = package; 49 | 50 | var commandService = 51 | ServiceProvider.GetService(typeof(IMenuCommandService)) as OleMenuCommandService; 52 | if (commandService == null) return; 53 | 54 | var menuCommandId = new CommandID(CommandSet, CommandId); 55 | var menuItem = new MenuCommand(this.MenuItemCallback, menuCommandId); 56 | commandService.AddCommand(menuItem); 57 | } 58 | 59 | /// 60 | /// Gets the instance of the command. 61 | /// 62 | public static StartTunnel Instance { get; private set; } 63 | 64 | /// 65 | /// Gets the service provider from the owner package. 66 | /// 67 | private IServiceProvider ServiceProvider => _package; 68 | 69 | /// 70 | /// Initializes the singleton instance of the command. 71 | /// 72 | /// Owner package, not null. 73 | public static void Initialize(Package package) 74 | { 75 | Instance = new StartTunnel(package); 76 | } 77 | 78 | /// 79 | /// This function is the callback used to execute the command when the menu item is clicked. 80 | /// See the constructor to see how the menu item is associated with this function using 81 | /// OleMenuCommandService service and MenuCommand class. 82 | /// 83 | /// Event sender. 84 | /// Event args. 85 | private void MenuItemCallback(object sender, EventArgs e) 86 | { 87 | var webApps = GetWebApps(); 88 | 89 | if (webApps.Count == 0) 90 | { 91 | ShowErrorMessage("Did not find any Web projects."); 92 | return; 93 | } 94 | 95 | var page = (OptionsPageGrid)_package.GetDialogPage(typeof(OptionsPageGrid)); 96 | var ngrok = new NgrokUtils(webApps, page.ExecutablePath, ShowErrorMessageAsync); 97 | 98 | var installPlease = false; 99 | if (!ngrok.NgrokIsInstalled()) 100 | { 101 | if (AskUserYesNoQuestion( 102 | "Ngrok 2.3.34 or above is not installed. Would you like me to download it from ngrok.com and install it for you?")) 103 | { 104 | installPlease = true; 105 | } 106 | else 107 | { 108 | return; 109 | } 110 | } 111 | 112 | ThreadHelper.JoinableTaskFactory.Run(async delegate 113 | { 114 | await TaskScheduler.Default; 115 | if (installPlease) 116 | { 117 | try 118 | { 119 | var installer = new NgrokInstaller(); 120 | page.ExecutablePath = await installer.InstallNgrok(); 121 | ngrok = new NgrokUtils(webApps, page.ExecutablePath, ShowErrorMessageAsync); 122 | } 123 | catch (NgrokDownloadException ngrokDownloadException) 124 | { 125 | await ShowErrorMessageAsync(ngrokDownloadException.Message); 126 | return; 127 | } 128 | } 129 | await ngrok.StartTunnelsAsync(); 130 | }); 131 | } 132 | 133 | private async System.Threading.Tasks.Task ShowErrorMessageAsync(string message) 134 | { 135 | await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); 136 | ShowErrorMessage(message); 137 | } 138 | 139 | private void ShowErrorMessage(string message) 140 | { 141 | VsShellUtilities.ShowMessageBox( 142 | this.ServiceProvider, 143 | message, 144 | "ngrok", 145 | OLEMSGICON.OLEMSGICON_CRITICAL, 146 | OLEMSGBUTTON.OLEMSGBUTTON_OK, 147 | OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); 148 | } 149 | 150 | private bool AskUserYesNoQuestion(string message) 151 | { 152 | var result = VsShellUtilities.ShowMessageBox( 153 | this.ServiceProvider, 154 | message, 155 | "ngrok", 156 | OLEMSGICON.OLEMSGICON_QUERY, 157 | OLEMSGBUTTON.OLEMSGBUTTON_YESNO, 158 | OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); 159 | 160 | return result == 6; // Yes 161 | } 162 | 163 | private Dictionary GetWebApps() 164 | { 165 | var webApps = new Dictionary(); 166 | var projects = GetSolutionProjects(); 167 | if (projects == null) return webApps; 168 | 169 | foreach (Project project in projects) 170 | { 171 | if (project.Properties == null) continue; // Project not loaded yet 172 | 173 | foreach (Property prop in project.Properties) 174 | { 175 | DebugWriteProp(prop); 176 | if (!PortPropertyNames.Contains(prop.Name)) continue; 177 | 178 | WebAppConfig webApp; 179 | 180 | if (prop.Name == "FileName") 181 | { 182 | if (prop.Value.ToString().EndsWith(".funproj")) 183 | { 184 | // Azure Functions app - use port 7071 185 | webApp = new WebAppConfig("7071"); 186 | LoadOptionsFromAppSettingsJson(project, webApp); 187 | } 188 | else 189 | { 190 | continue; // FileName property not relevant otherwise 191 | } 192 | } 193 | else 194 | { 195 | webApp = new WebAppConfig(prop.Value.ToString()); 196 | if (!webApp.IsValid) continue; 197 | if (IsAspNetCoreProject(prop.Name)) 198 | { 199 | LoadOptionsFromAppSettingsJson(project, webApp); 200 | } 201 | else 202 | { 203 | LoadOptionsFromWebConfig(project, webApp); 204 | } 205 | } 206 | 207 | webApps.Add(project.Name, webApp); 208 | break; 209 | } 210 | } 211 | return webApps; 212 | } 213 | 214 | private bool IsAspNetCoreProject(string propName) 215 | { 216 | return propName == "ProjectUrl"; 217 | } 218 | 219 | private static void LoadOptionsFromWebConfig(Project project, WebAppConfig webApp) 220 | { 221 | foreach (ProjectItem item in project.ProjectItems) 222 | { 223 | if (item.Name.ToLower() != "web.config") continue; 224 | 225 | var path = item.FileNames[0]; 226 | var webConfig = XDocument.Load(path); 227 | var appSettings = webConfig.Descendants("appSettings").FirstOrDefault(); 228 | webApp.SubDomain = appSettings?.Descendants("add") 229 | .FirstOrDefault(x => x.Attribute("key")?.Value == NgrokSubdomainSettingName) 230 | ?.Attribute("value")?.Value; 231 | break; 232 | } 233 | } 234 | 235 | private static void LoadOptionsFromAppSettingsJson(Project project, WebAppConfig webApp) 236 | { 237 | // Read the settings from the project's appsettings.json first 238 | foreach (ProjectItem item in project.ProjectItems) 239 | { 240 | if (item.Name.ToLower() != "appsettings.json") continue; 241 | 242 | ReadOptionsFromJsonFile(item.FileNames[0], webApp); 243 | } 244 | 245 | // Override any additional settings from the secrets.json file if it exists 246 | var userSecretsId = project.Properties.OfType() 247 | .FirstOrDefault(x => x.Name.Equals("UserSecretsId", StringComparison.OrdinalIgnoreCase))?.Value as String; 248 | 249 | if (string.IsNullOrEmpty(userSecretsId)) return; 250 | 251 | var appdata = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); 252 | var secretsFile = Path.Combine(appdata, "Microsoft", "UserSecrets", userSecretsId, "secrets.json"); 253 | 254 | ReadOptionsFromJsonFile(secretsFile, webApp); 255 | } 256 | 257 | private static void ReadOptionsFromJsonFile(string path, WebAppConfig webApp) 258 | { 259 | if (!File.Exists(path)) return; 260 | 261 | var json = File.ReadAllText(path); 262 | var appSettings = JsonConvert.DeserializeAnonymousType(json, 263 | new { IsEncrypted = false, Values = new Dictionary() }); 264 | 265 | if (appSettings.Values != null && appSettings.Values.TryGetValue(NgrokSubdomainSettingName, out var subdomain)) 266 | { 267 | webApp.SubDomain = subdomain; 268 | } 269 | } 270 | 271 | private static void DebugWriteProp(Property prop) 272 | { 273 | try 274 | { 275 | Debug.WriteLine($"{prop.Name} = {prop.Value}"); 276 | } 277 | catch 278 | { 279 | // ignored 280 | } 281 | } 282 | 283 | private IEnumerable GetSolutionProjects() 284 | { 285 | var solution = (ServiceProvider.GetService(typeof(SDTE)) as DTE)?.Solution; 286 | return solution == null ? null : ProcessProjects(solution.Projects.Cast()); 287 | } 288 | 289 | private static IEnumerable ProcessProjects(IEnumerable projects) 290 | { 291 | var newProjectsList = new List(); 292 | foreach (var p in projects) 293 | { 294 | 295 | if (p.Kind == ProjectKinds.vsProjectKindSolutionFolder) 296 | { 297 | newProjectsList.AddRange(ProcessProjects(GetSolutionFolderProjects(p))); 298 | } 299 | else 300 | { 301 | newProjectsList.Add(p); 302 | } 303 | } 304 | 305 | return newProjectsList; 306 | } 307 | 308 | private static IEnumerable GetSolutionFolderProjects(Project project) 309 | { 310 | return project.ProjectItems.Cast() 311 | .Select(item => item.SubProject) 312 | .Where(subProject => subProject != null) 313 | .ToList(); 314 | } 315 | } 316 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/StartTunnelPackage.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.InteropServices; 3 | using Microsoft.VisualStudio.Shell; 4 | 5 | namespace NgrokExtensions 6 | { 7 | /// 8 | /// This is the class that implements the package exposed by this assembly. 9 | /// 10 | /// 11 | /// 12 | /// The minimum requirement for a class to be considered a valid package for Visual Studio 13 | /// is to implement the IVsPackage interface and register itself with the shell. 14 | /// This package uses the helper classes defined inside the Managed Package Framework (MPF) 15 | /// to do it: it derives from the Package class that provides the implementation of the 16 | /// IVsPackage interface and uses the registration attributes defined in the framework to 17 | /// register itself and its components with the shell. These attributes tell the pkgdef creation 18 | /// utility what data to put into .pkgdef file. 19 | /// 20 | /// 21 | /// To get loaded into VS, the package must be referred by <Asset Type="Microsoft.VisualStudio.VsPackage" ...> in .vsixmanifest file. 22 | /// 23 | /// 24 | [PackageRegistration(UseManagedResourcesOnly = true)] 25 | [InstalledProductRegistration("#110", "#112", "1.0", IconResourceID = 400)] // Info on this package for Help/About 26 | [ProvideMenuResource("Menus.ctmenu", 1)] 27 | [Guid(StartTunnelPackage.PackageGuidString)] 28 | [ProvideOptionPage(typeof(OptionsPageGrid), "ngrok", "Options", 0, 0, true)] 29 | [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "pkgdef, VS and vsixmanifest are valid VS terms")] 30 | public sealed class StartTunnelPackage : Package 31 | { 32 | /// 33 | /// StartTunnelPackage GUID string. 34 | /// 35 | public const string PackageGuidString = "9f845cfc-84ef-4aac-9826-d46a83744fb4"; 36 | 37 | public string ExecutablePath 38 | { 39 | get 40 | { 41 | OptionsPageGrid page = (OptionsPageGrid)this.GetDialogPage(typeof(OptionsPageGrid)); 42 | return page.ExecutablePath; 43 | } 44 | } 45 | 46 | /// 47 | /// Initializes a new instance of the class. 48 | /// 49 | public StartTunnelPackage() 50 | { 51 | // Inside this method you can place any initialization code that does not require 52 | // any Visual Studio service because at this point the package object is created but 53 | // not sited yet inside Visual Studio environment. The place to do all the other 54 | // initialization is the Initialize method. 55 | } 56 | 57 | #region Package Members 58 | 59 | /// 60 | /// Initialization of the package; this method is called right after the package is sited, so this is the place 61 | /// where you can put all the initialization code that rely on services provided by VisualStudio. 62 | /// 63 | protected override void Initialize() 64 | { 65 | StartTunnel.Initialize(this); 66 | base.Initialize(); 67 | } 68 | 69 | #endregion 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/StartTunnelPackage.vsct: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 55 | 62 | 63 | 64 | 65 | 66 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/VSPackage.resx: -------------------------------------------------------------------------------- 1 |  2 | 12 | 13 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | text/microsoft-resx 120 | 121 | 122 | 2.0 123 | 124 | 125 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 126 | 127 | 128 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 129 | 130 | 131 | 132 | StartTunnel Extension 133 | 134 | 135 | StartTunnel Visual Studio Extension Detailed Info 136 | 137 | 138 | Resources\tunnel.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a 139 | 140 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/WebAppConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace NgrokExtensions 4 | { 5 | public class WebAppConfig 6 | { 7 | private static readonly Regex HttpsPattern = new Regex(@"^https://[^/]+"); 8 | private static readonly Regex NumberPattern = new Regex(@"\d+"); 9 | 10 | public bool IsValid 11 | { 12 | get 13 | { 14 | return NgrokAddress != null; 15 | } 16 | } 17 | 18 | public string NgrokAddress { get; } 19 | public string SubDomain { get; set; } 20 | public string PublicUrl { get; set; } 21 | 22 | public WebAppConfig(string settingValue) 23 | { 24 | NgrokAddress = ParseNgrokAddress(settingValue); 25 | } 26 | 27 | private string ParseNgrokAddress(string settingValue) 28 | { 29 | var match = HttpsPattern.Match(settingValue); 30 | if (match.Success) return match.Value; 31 | 32 | match = NumberPattern.Match(settingValue); 33 | if (match.Success) return $"localhost:{match.Value}"; 34 | 35 | return null; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensions/source.extension.vsixmanifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Ngrok Extensions 6 | Use ngrok quickly and easily from within Visual Studio. ngrok allows you to expose a local server behind a NAT or firewall to the internet. "Demo without deploying." 7 | https://github.com/twilio-labs/NgrokExtensions 8 | LICENSE 9 | Resources\tunnel.ico 10 | Resources\PreviewImage.png 11 | Visual Studio, Extension, Web, ngrok, tunnel 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/NgrokExtensionsSolution/NgrokExtensionsSolution.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32107.320 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NgrokExtensions.2019", "NgrokExtensions\NgrokExtensions.2019.csproj", "{8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NgrokExtensions.Test", "NgrokExtensions.Test\NgrokExtensions.Test.csproj", "{A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}" 9 | EndProject 10 | Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "NgrokExtensions.Shared", "NgrokExtensions.Shared\NgrokExtensions.Shared.shproj", "{386561BA-7F3A-4682-9288-D2EFE29DAAEA}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NgrokExtensions.2022", "NgrokExtensions.2022\NgrokExtensions.2022.csproj", "{BF64FAC8-EF65-43EE-8229-21565F95FD68}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DE711E8B-0A6D-4446-8DE2-0D63911791EA}" 15 | ProjectSection(SolutionItems) = preProject 16 | ..\..\.github\workflows\build.yaml = ..\..\.github\workflows\build.yaml 17 | EndProjectSection 18 | EndProject 19 | Global 20 | GlobalSection(SharedMSBuildProjectFiles) = preSolution 21 | NgrokExtensions.Shared\NgrokExtensions.Shared.projitems*{386561ba-7f3a-4682-9288-d2efe29daaea}*SharedItemsImports = 13 22 | EndGlobalSection 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Debug|x86 = Debug|x86 26 | Release|Any CPU = Release|Any CPU 27 | Release|x86 = Release|x86 28 | EndGlobalSection 29 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 30 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Debug|x86.ActiveCfg = Debug|x86 33 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Debug|x86.Build.0 = Debug|x86 34 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Release|x86.ActiveCfg = Release|x86 37 | {8FCAF75B-BCDF-415B-AAE2-ECEBAF53DCD1}.Release|x86.Build.0 = Release|x86 38 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Debug|x86.ActiveCfg = Debug|Any CPU 41 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Debug|x86.Build.0 = Debug|Any CPU 42 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Release|x86.ActiveCfg = Release|Any CPU 45 | {A409F2F2-6E3A-45DD-8C7E-8DD8569E9023}.Release|x86.Build.0 = Release|Any CPU 46 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Debug|x86.ActiveCfg = Debug|x86 49 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Debug|x86.Build.0 = Debug|x86 50 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Release|x86.ActiveCfg = Release|x86 53 | {BF64FAC8-EF65-43EE-8229-21565F95FD68}.Release|x86.Build.0 = Release|x86 54 | EndGlobalSection 55 | GlobalSection(SolutionProperties) = preSolution 56 | HideSolutionNode = FALSE 57 | EndGlobalSection 58 | GlobalSection(ExtensibilityGlobals) = postSolution 59 | SolutionGuid = {2F9FB4B6-B2C6-4885-8E50-C400F8477080} 60 | EndGlobalSection 61 | EndGlobal 62 | -------------------------------------------------------------------------------- /vs-publish.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/vsix-publish", 3 | "categories": ["testing", "web"], 4 | "identity": { 5 | "internalName": "NgrokExtensions" 6 | }, 7 | "overview": "README.md", 8 | "priceCategory": "free", 9 | "publisher": "DavidProthero", 10 | "private": false, 11 | "qna": true, 12 | "repo": "https://github.com/twilio-labs/NgrokExtensions" 13 | } 14 | -------------------------------------------------------------------------------- /vs-publish.ps1: -------------------------------------------------------------------------------- 1 | $VisualStudioVersion = "15.0"; 2 | $VSINSTALLDIR = $(Get-ItemProperty "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\SxS\VS7").$VisualStudioVersion; 3 | $VSIXPublisherPath = $VSINSTALLDIR + "VSSDK\VisualStudioIntegration\Tools\Bin\VsixPublisher.exe" 4 | 5 | &$VSIXPublisherPath publish -payload .\src\NgrokExtensionsSolution\NgrokExtensions\bin\Release\NgrokExtensions.vsix -publishManifest .\vs-publish.json -personalAccessToken $Env:MSFT_PAT -ignoreWarnings "VSIXValidatorWarning03" 6 | --------------------------------------------------------------------------------