diff --git a/.gitignore b/.gitignore
index 0e1fbb7..1f4ba34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@ bld/
[Oo]bj/
[Ll]og/
.vs/
+.vscode/
# NuGet Packages
*.nupkg
diff --git a/docs/ScreenShot-MenuItems.png b/docs/ScreenShot-MenuItems.png
index 2c55de1..2910827 100644
Binary files a/docs/ScreenShot-MenuItems.png and b/docs/ScreenShot-MenuItems.png differ
diff --git a/docs/ScreenShot-ToolsOptions.png b/docs/ScreenShot-ToolsOptions.png
index dc24594..e53fd5f 100644
Binary files a/docs/ScreenShot-ToolsOptions.png and b/docs/ScreenShot-ToolsOptions.png differ
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..128d95e
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme.md b/readme.md
index fe8ab48..2343c51 100644
--- a/readme.md
+++ b/readme.md
@@ -12,27 +12,41 @@ Suess Labs consulting is sponsored by _Xeno Innovations, Inc._
## Overview
-Now developers can build, deploy and debug projects on their remote Linux (Ubuntu, Raspberry PI, etc) devices! Customize your SSH connection to use either a _password_ or a _private key_.
+Now developers can build, deploy and debug projects on their remote Linux (Ubuntu, Raspberry PI, etc) devices! Customize your SSH connection to use either a _password_, a _private key_, or an SSH CA-signed certificate.
If you enjoy using the extension, please give it a ★★★★★ rating on the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SuessLabs.VSLinuxDebugger).
+### What's new
+
+* **SSH CA certificate authentication** — supports a private key with an accompanying `-cert.pub` certificate (auto-detected next to the key, or set explicitly), in addition to password and plain private-key auth.
+* **Sudo-elevated debugger launch** — an opt-in setting launches `vsdbg` via a configurable `sudo` command, for debuggees running with elevated or ambient capabilities that the debugger must match to attach.
+* **Deploys via `dotnet publish`** — always produces a real native executable (self-contained or framework-dependent, your choice), with the executable bit restored after transfer (lost by default over tar/scp from Windows).
+* **Environment variables for the debuggee** — pass `KEY=VALUE` pairs through to the remote process, for programs that read required configuration from the environment.
+* **Configurable pre/post-deploy commands and attach-to-running-process** — run arbitrary shell commands before/after each deploy (i.e. stopping/restarting a systemd service), and optionally attach the debugger to that already-running process (via a configurable PID-lookup command) instead of launching a new one.
+* **Attach Only** — reattach to a running/deployed process without rebuilding or redeploying.
+* **Stop button** — cancels an in-progress build/deploy/debug between steps.
+* **Modernized Options UI** — settings are split across focused Tools > Options pages (Remote Host, Remote Credentials, Remote Debugger, Remote Launch, Local) built with a real WPF UI (checkboxes, dynamic show/hide for dependent fields) instead of a single page with a WinForms PropertyGrid.
+* Support for Visual Studio 2026 and newer .NET target frameworks (net8.0, net10.0).
+
### Supported Remote OS
-The following Linux distrobutions have been validated and are supported.
+The following Linux distributions have been validated and are supported.
* Ubuntu (20.04 LTS, 22.04 LTS, 24.x LTS)
-* Raspberry PI OS
+* Raspberry Pi OS
+* Debian-based embedded Linux images (i.e. Yocto/OpenEmbedded targets), where `curl` and an SSH server are available
### Usage

-* Build and upload to remote devices
-* Remote debugging*
- * _This is still in the experimental stages. Please use VS' Attach to Process if you have issues_
+* **Build and Deploy** — build, upload to the remote device, and (re)start it if pre/post-deploy commands are configured.
+* **Build, Deploy and Debug** — the above, then attach the debugger (or launch it, for a fresh non-supervised process).
+* **Attach Only (no build/deploy)** — reattach to whatever's already running/deployed, without rebuilding or redeploying.
+* **Stop** — cancels an in-progress build/deploy/debug between steps.
* VS Linux Debugger will automatically detect and install `vsdbg` for you!
-For GUI app debugging, you can use the _Build and Deploy_ feature, however, you must manually _Attach to Process_ via SSH using Visual Studio at this time.
+For GUI app debugging, you must manually _Attach to Process_ via SSH using Visual Studio at this time (see below).
### Getting Started
@@ -46,9 +60,9 @@ sudo apt install curl
**Windows**:
1. Open Visual Studio (VS) > Tools > Options > **Linux Debugger**
-2. **Input:** Remote Host IP address
-3. **Input:** Remote's User Name and Password
-4. VS > Extensions > Linux Debugger > **Build, Deploy, Debug**
+2. Configure the **Remote Host** page (IP address) and **Remote Credentials** page (user name, and either a password, a private key, or an SSH CA certificate)
+3. Optionally, configure **Remote Debugger** (deploy folder, self-contained/RID) and **Remote Launch** (env vars, pre/post-deploy commands, attach-to-running-process, sudo, X11)
+4. VS > Extensions > Linux Debugger > **Build, Deploy and Debug**

@@ -70,53 +84,7 @@ For GUI projects, you can use **Build and Deploy** and then manually attach to t
This will save you 1.5 minutes on every build of manual uploading and updating rights via `chown -R`.
-### Manually Attaching (for Command line apps)
-
-For CLI projects, you can use **Build and Deploy** and then manually attach to the process via SSH by using Visual Studio's built-in tool (similar to above).
-
-You may have to manually interrupt your app via `Console.ReadLine();` high-up in your entry-point (i.e. `main()`).
-
-1. Deploy to remote machine via
- 1. Extensions > Linux Debugger > **"Build and Deploy"**
-2. Run your CLI app on remote machine
- 1. `dotnet MyCliApp.dll`
-3. Debug > **"Attach to Process.."**
- 1. Connection Type: **SSH**
- 2. Connection Target: **(Remote machine's IP)**
- 3. (Select process)
- 4. Click, **Attach**
- 5. Check, **"Managed (.NET Core for Unix)"**
- 6. Click, **OK**
-4. Continue your application, if using a manual interrupt (i.e. `Console.ReadLine();`)
-
-This will save you 1.5 minutes on every build of manual uploading and updating rights via `chown -R`.
-
-## How To Generate Private Key (optional)
-
-The following steps are options if you wish to use an SSH Private Key. These steps were written for Windows 10, however, on Linux the steps are similar.
-
-1. Open PowerShell:
-2. **Generate key** (_with old PEM format_)
- 1. `ssh-keygen -m PEM -t rsa -b 4096`
- 2. In the future, we'll be able to use `ssh-keygen`.. just not yet.
-3. Set output name (_default is okay for basic setups_)
-4. Input a passphrase for the key _(OPTIONAL)_
-5. Windows will now generate your RSA public/private key pair.
- 1. Default location: `%UserProfile%\.ssh` (WINOWS)
- 2. The public key will be stored as `id_rsa.pub` in the directory
-6. **Upload the public key** to your remote machine
- 1. Navigate to folder, `~/.ssh/` on Linux device
- 2. If `~/.ssh/authorized_keys` exists, append the contents of `id_rsa.pub` to the next line.
- 3. If it does not exist, simply upload `id_rsa.pub` and rename it to, `authorized_keys`
-7. DONE!
-
-## Used By
-
-* [SuessLabs](https://suesslabs.com) and [Xeno Innovations](https://xenoinc.com)
-* [Wilderness Labs](https://github.com/WildernessLabs)
-* [Omnicell, Inc.](https://omnicell.com)
-
-_Want your name added? Reach out to us_
+If your remote device uses **SSH CA-signed certificates** instead of `authorized_keys` (i.e. `TrustedUserCAKeys` configured in `sshd_config`), point "SSH Private Key File" at your CA-issued private key; the matching `-cert.pub` certificate is picked up automatically if it sits next to the key, or can be set explicitly via "SSH Certificate File" on the Remote Credentials options page.
## Future Features
diff --git a/release-notes.md b/release-notes.md
index 830e730..d637168 100644
--- a/release-notes.md
+++ b/release-notes.md
@@ -4,6 +4,17 @@
This document contains the release information for the project.
+### 3.0.0
+
+* Added: SSH CA certificate authentication (a private key with an accompanying `-cert.pub` certificate, auto-detected or set explicitly), in addition to password and plain private-key auth.
+* Added: Sudo-elevated debugger launch, for debuggees running with elevated or ambient capabilities that the debugger must match to attach.
+* Added: Configurable pre/post-deploy shell commands, and an "Attach to Already-Running Process" mode (with a configurable PID-lookup command) for debuggees managed by an external supervisor (i.e. a systemd service).
+* Added: "Attach Only" menu item to reattach to a running/deployed process without rebuilding or redeploying.
+* Added: A "Stop" button to cancel an in-progress build/deploy/debug between steps.
+* Update: Deployment now always goes through `dotnet publish` (self-contained or framework-dependent), producing a real native executable either way, instead of uploading a plain build's output folder.
+* Update: Options window split across focused Tools > Options pages (Remote Host, Remote Credentials, Remote Debugger, Remote Launch, Local) with a real WPF UI (checkboxes, dynamic show/hide for dependent fields) instead of a single page with a WinForms PropertyGrid.
+* Update: Support for Visual Studio 2026 and newer .NET target frameworks (net8.0, net10.0).
+
### 2.3.0 - 2024-12-02
Early patch release before Christmas break. Effort moving forward will be for the upcoming 3.0 release.
diff --git a/sandbox/ConsoleNet6.sln b/sandbox/Console.sln
similarity index 87%
rename from sandbox/ConsoleNet6.sln
rename to sandbox/Console.sln
index f2928a1..6313930 100644
--- a/sandbox/ConsoleNet6.sln
+++ b/sandbox/Console.sln
@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.2.32317.152
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleNet6", "ConsoleNet6\ConsoleNet6.csproj", "{F181E39A-CCE1-45C1-B196-0CE759BB86AB}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "Console\Console.csproj", "{F181E39A-CCE1-45C1-B196-0CE759BB86AB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/sandbox/ConsoleNet6/ConsoleNet6.csproj b/sandbox/Console/Console.csproj
similarity index 69%
rename from sandbox/ConsoleNet6/ConsoleNet6.csproj
rename to sandbox/Console/Console.csproj
index 4d39f1d..58ef372 100644
--- a/sandbox/ConsoleNet6/ConsoleNet6.csproj
+++ b/sandbox/Console/Console.csproj
@@ -2,10 +2,9 @@
Exe
- net6.0
+ net8.0;net10.0
enable
enable
- false
portable
diff --git a/sandbox/ConsoleNet6/Program.cs b/sandbox/Console/Program.cs
similarity index 100%
rename from sandbox/ConsoleNet6/Program.cs
rename to sandbox/Console/Program.cs
diff --git a/sandbox/GuiNet6.sln b/sandbox/Gui.sln
similarity index 88%
rename from sandbox/GuiNet6.sln
rename to sandbox/Gui.sln
index e6d2b2f..56d2724 100644
--- a/sandbox/GuiNet6.sln
+++ b/sandbox/Gui.sln
@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.3.32804.467
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GuiNet6", "GuiNet6\GuiNet6.csproj", "{98F164D1-51CF-4BC1-8F9D-8DFB541E57F5}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gui", "Gui\Gui.csproj", "{98F164D1-51CF-4BC1-8F9D-8DFB541E57F5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/sandbox/Gui/App.axaml b/sandbox/Gui/App.axaml
new file mode 100644
index 0000000..0f926dc
--- /dev/null
+++ b/sandbox/Gui/App.axaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sandbox/Gui/App.axaml.cs b/sandbox/Gui/App.axaml.cs
new file mode 100644
index 0000000..0d69487
--- /dev/null
+++ b/sandbox/Gui/App.axaml.cs
@@ -0,0 +1,37 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Gui.ViewModels;
+using Gui.Views;
+
+namespace Gui;
+
+public class App : Application
+{
+ /// App entry point.
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+#if DEBUG
+ this.AttachDeveloperTools();
+#endif
+ }
+
+ /// Called once the Avalonia framework has finished initializing.
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // Compose views/view-models directly - no DI container, no region manager.
+ var shellViewModel = new ShellWindowViewModel();
+ var shellWindow = new ShellWindow
+ {
+ DataContext = shellViewModel,
+ };
+
+ desktop.MainWindow = shellWindow;
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/sandbox/GuiNet6/Assets/avalonia-logo.ico b/sandbox/Gui/Assets/avalonia-logo.ico
similarity index 100%
rename from sandbox/GuiNet6/Assets/avalonia-logo.ico
rename to sandbox/Gui/Assets/avalonia-logo.ico
diff --git a/sandbox/Gui/Gui.csproj b/sandbox/Gui/Gui.csproj
new file mode 100644
index 0000000..80702a8
--- /dev/null
+++ b/sandbox/Gui/Gui.csproj
@@ -0,0 +1,22 @@
+
+
+ WinExe
+ net8.0;net10.0
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ShellWindow.axaml
+
+
+
diff --git a/sandbox/GuiNet6/Program.cs b/sandbox/Gui/Program.cs
similarity index 83%
rename from sandbox/GuiNet6/Program.cs
rename to sandbox/Gui/Program.cs
index 490f7ad..5c194d5 100644
--- a/sandbox/GuiNet6/Program.cs
+++ b/sandbox/Gui/Program.cs
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
-using Avalonia.ReactiveUI;
+using ReactiveUI.Avalonia;
-namespace GuiNet6;
+namespace Gui;
internal class Program
{
@@ -17,11 +17,10 @@ public static AppBuilder BuildAvaloniaApp() => AppBuilder
})
.With(new Win32PlatformOptions
{
- EnableMultitouch = true,
- AllowEglInitialization = true,
+ RenderingMode = [Win32RenderingMode.AngleEgl, Win32RenderingMode.Software],
})
.UseSkia()
- .UseReactiveUI()
+ .UseReactiveUI(rxui => { })
.LogToTrace();
// Initialization code. Don't use any Avalonia, third-party APIs or any
diff --git a/sandbox/GuiNet6/Properties/launchSettings.json b/sandbox/Gui/Properties/launchSettings.json
similarity index 92%
rename from sandbox/GuiNet6/Properties/launchSettings.json
rename to sandbox/Gui/Properties/launchSettings.json
index da8521b..efdff85 100644
--- a/sandbox/GuiNet6/Properties/launchSettings.json
+++ b/sandbox/Gui/Properties/launchSettings.json
@@ -1,6 +1,6 @@
{
"profiles": {
- "GuiNet6": {
+ "Gui": {
"commandName": "Project"
},
"WSL": {
diff --git a/sandbox/Gui/ViewModels/DashboardViewModel.cs b/sandbox/Gui/ViewModels/DashboardViewModel.cs
new file mode 100644
index 0000000..171f859
--- /dev/null
+++ b/sandbox/Gui/ViewModels/DashboardViewModel.cs
@@ -0,0 +1,21 @@
+using System.Windows.Input;
+using ReactiveUI;
+
+namespace Gui.ViewModels;
+
+public class DashboardViewModel : ViewModelBase
+{
+ public DashboardViewModel()
+ {
+ Title = "Dashboard View!";
+
+ CmdBreakPoint = ReactiveCommand.Create(() =>
+ {
+ // Force a breakpoint
+ System.Diagnostics.Debug.WriteLine("Breakpoint triggering");
+ System.Diagnostics.Debugger.Break();
+ });
+ }
+
+ public ICommand CmdBreakPoint { get; }
+}
diff --git a/sandbox/GuiNet6/ViewModels/SettingsViewModel.cs b/sandbox/Gui/ViewModels/SettingsViewModel.cs
similarity index 78%
rename from sandbox/GuiNet6/ViewModels/SettingsViewModel.cs
rename to sandbox/Gui/ViewModels/SettingsViewModel.cs
index 986ee0c..662872c 100644
--- a/sandbox/GuiNet6/ViewModels/SettingsViewModel.cs
+++ b/sandbox/Gui/ViewModels/SettingsViewModel.cs
@@ -1,4 +1,4 @@
-namespace GuiNet6.ViewModels;
+namespace Gui.ViewModels;
public class SettingsViewModel : ViewModelBase
{
diff --git a/sandbox/Gui/ViewModels/ShellWindowViewModel.cs b/sandbox/Gui/ViewModels/ShellWindowViewModel.cs
new file mode 100644
index 0000000..28473d4
--- /dev/null
+++ b/sandbox/Gui/ViewModels/ShellWindowViewModel.cs
@@ -0,0 +1,35 @@
+using ReactiveUI;
+
+namespace Gui.ViewModels;
+
+public class ShellWindowViewModel : ViewModelBase
+{
+ private ViewModelBase _currentPage;
+
+ public ShellWindowViewModel()
+ {
+ Title = "Sample Avalonia - Navigation";
+
+ Sidebar = new SidebarViewModel(NavigateTo);
+
+ var dashboard = new DashboardViewModel();
+ _currentPage = dashboard;
+ }
+
+ /// Gets the sidebar view model hosted in this shell.
+ public SidebarViewModel Sidebar { get; }
+
+ /// Gets or sets the view model currently displayed in the shell's content area.
+ public ViewModelBase CurrentPage
+ {
+ get => _currentPage;
+ set => this.RaiseAndSetIfChanged(ref _currentPage, value);
+ }
+
+ /// Switches the content area to the given view model.
+ /// View model to display.
+ private void NavigateTo(ViewModelBase viewModel)
+ {
+ CurrentPage = viewModel;
+ }
+}
diff --git a/sandbox/Gui/ViewModels/SidebarViewModel.cs b/sandbox/Gui/ViewModels/SidebarViewModel.cs
new file mode 100644
index 0000000..0ed6502
--- /dev/null
+++ b/sandbox/Gui/ViewModels/SidebarViewModel.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Windows.Input;
+using ReactiveUI;
+
+namespace Gui.ViewModels;
+
+public class SidebarViewModel : ViewModelBase
+{
+ private readonly Action _navigate;
+
+ public SidebarViewModel(Action navigate)
+ {
+ _navigate = navigate;
+
+ Title = "Navigation";
+
+ CmdDashboard = ReactiveCommand.Create(() => _navigate(new DashboardViewModel()));
+ CmdSettings = ReactiveCommand.Create(() => _navigate(new SettingsViewModel()));
+ }
+
+ public ICommand CmdDashboard { get; }
+
+ public ICommand CmdSettings { get; }
+}
diff --git a/sandbox/Gui/ViewModels/ViewModelBase.cs b/sandbox/Gui/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..59888be
--- /dev/null
+++ b/sandbox/Gui/ViewModels/ViewModelBase.cs
@@ -0,0 +1,15 @@
+using ReactiveUI;
+
+namespace Gui.ViewModels;
+
+public class ViewModelBase : ReactiveObject
+{
+ private string _title = string.Empty;
+
+ /// Gets or sets the title of the View.
+ public string Title
+ {
+ get => _title;
+ set => this.RaiseAndSetIfChanged(ref _title, value);
+ }
+}
diff --git a/sandbox/GuiNet6/Views/DashboardView.axaml b/sandbox/Gui/Views/DashboardView.axaml
similarity index 73%
rename from sandbox/GuiNet6/Views/DashboardView.axaml
rename to sandbox/Gui/Views/DashboardView.axaml
index 91ec7a2..2e5a1e5 100644
--- a/sandbox/GuiNet6/Views/DashboardView.axaml
+++ b/sandbox/Gui/Views/DashboardView.axaml
@@ -2,13 +2,13 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:prism="http://prismlibrary.com/"
- prism:ViewModelLocator.AutoWireViewModel="True"
+ xmlns:vm="using:Gui.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300"
- x:Class="GuiNet6.Views.DashboardView">
+ x:Class="Gui.Views.DashboardView"
+ x:DataType="vm:DashboardViewModel">
-
+
diff --git a/sandbox/GuiNet6/Views/DashboardView.axaml.cs b/sandbox/Gui/Views/DashboardView.axaml.cs
similarity index 91%
rename from sandbox/GuiNet6/Views/DashboardView.axaml.cs
rename to sandbox/Gui/Views/DashboardView.axaml.cs
index f8f7764..c0a25b0 100644
--- a/sandbox/GuiNet6/Views/DashboardView.axaml.cs
+++ b/sandbox/Gui/Views/DashboardView.axaml.cs
@@ -2,7 +2,7 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
-namespace GuiNet6.Views;
+namespace Gui.Views;
public partial class DashboardView : UserControl
{
diff --git a/sandbox/GuiNet6/Views/SettingsView.axaml b/sandbox/Gui/Views/SettingsView.axaml
similarity index 66%
rename from sandbox/GuiNet6/Views/SettingsView.axaml
rename to sandbox/Gui/Views/SettingsView.axaml
index 985fea5..172a1fc 100644
--- a/sandbox/GuiNet6/Views/SettingsView.axaml
+++ b/sandbox/Gui/Views/SettingsView.axaml
@@ -2,12 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:prism="http://prismlibrary.com/"
- prism:ViewModelLocator.AutoWireViewModel="True"
+ xmlns:vm="using:Gui.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
- x:Class="GuiNet6.Views.SettingsView">
- >
-
+ x:Class="Gui.Views.SettingsView"
+ x:DataType="vm:SettingsViewModel">
+
+
-
+
diff --git a/sandbox/GuiNet6/Views/SettingsView.axaml.cs b/sandbox/Gui/Views/SettingsView.axaml.cs
similarity index 91%
rename from sandbox/GuiNet6/Views/SettingsView.axaml.cs
rename to sandbox/Gui/Views/SettingsView.axaml.cs
index 7f4faf3..f927790 100644
--- a/sandbox/GuiNet6/Views/SettingsView.axaml.cs
+++ b/sandbox/Gui/Views/SettingsView.axaml.cs
@@ -2,7 +2,7 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
-namespace GuiNet6.Views;
+namespace Gui.Views;
public partial class SettingsView : UserControl
{
diff --git a/sandbox/GuiNet6/Views/ShellWindow.axaml b/sandbox/Gui/Views/ShellWindow.axaml
similarity index 62%
rename from sandbox/GuiNet6/Views/ShellWindow.axaml
rename to sandbox/Gui/Views/ShellWindow.axaml
index f39c719..3e171e7 100644
--- a/sandbox/GuiNet6/Views/ShellWindow.axaml
+++ b/sandbox/Gui/Views/ShellWindow.axaml
@@ -2,12 +2,11 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:core="clr-namespace:GuiNet6;assembly=GuiNet6"
- xmlns:local="using:GuiNet6.Views"
- xmlns:prism="http://prismlibrary.com/"
- prism:ViewModelLocator.AutoWireViewModel="True"
+ xmlns:local="using:Gui.Views"
+ xmlns:vm="using:Gui.ViewModels"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="350"
- x:Class="GuiNet6.Views.ShellWindow"
+ x:Class="Gui.Views.ShellWindow"
+ x:DataType="vm:ShellWindowViewModel"
Background="Transparent"
Icon="/Assets/avalonia-logo.ico"
Width="600" Height="300"
@@ -27,20 +26,13 @@
-
-
+
-
-
-
+ Content="{Binding CurrentPage}" />
diff --git a/sandbox/GuiNet6/Views/ShellWindow.axaml.cs b/sandbox/Gui/Views/ShellWindow.axaml.cs
similarity index 77%
rename from sandbox/GuiNet6/Views/ShellWindow.axaml.cs
rename to sandbox/Gui/Views/ShellWindow.axaml.cs
index 5dad51e..3ed2eef 100644
--- a/sandbox/GuiNet6/Views/ShellWindow.axaml.cs
+++ b/sandbox/Gui/Views/ShellWindow.axaml.cs
@@ -2,16 +2,13 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
-namespace GuiNet6.Views;
+namespace Gui.Views;
public partial class ShellWindow : Window
{
public ShellWindow()
{
InitializeComponent();
-#if DEBUG
- this.AttachDevTools();
-#endif
}
private void InitializeComponent()
diff --git a/sandbox/GuiNet6/Views/SidebarView.axaml b/sandbox/Gui/Views/SidebarView.axaml
similarity index 71%
rename from sandbox/GuiNet6/Views/SidebarView.axaml
rename to sandbox/Gui/Views/SidebarView.axaml
index 640f616..2f301e7 100644
--- a/sandbox/GuiNet6/Views/SidebarView.axaml
+++ b/sandbox/Gui/Views/SidebarView.axaml
@@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:prism="http://prismlibrary.com/"
- prism:ViewModelLocator.AutoWireViewModel="True"
+ xmlns:vm="using:Gui.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
- x:Class="GuiNet6.Views.SidebarView">
+ x:Class="Gui.Views.SidebarView"
+ x:DataType="vm:SidebarViewModel">
-
-
diff --git a/sandbox/GuiNet6/Views/SidebarView.axaml.cs b/sandbox/Gui/Views/SidebarView.axaml.cs
similarity index 91%
rename from sandbox/GuiNet6/Views/SidebarView.axaml.cs
rename to sandbox/Gui/Views/SidebarView.axaml.cs
index 5cb6982..a2ffe47 100644
--- a/sandbox/GuiNet6/Views/SidebarView.axaml.cs
+++ b/sandbox/Gui/Views/SidebarView.axaml.cs
@@ -2,7 +2,7 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
-namespace GuiNet6.Views;
+namespace Gui.Views;
public partial class SidebarView : UserControl
{
diff --git a/sandbox/GuiNet6/App.axaml b/sandbox/GuiNet6/App.axaml
deleted file mode 100644
index d3a6f62..0000000
--- a/sandbox/GuiNet6/App.axaml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/sandbox/GuiNet6/App.axaml.cs b/sandbox/GuiNet6/App.axaml.cs
deleted file mode 100644
index 4f5aaa8..0000000
--- a/sandbox/GuiNet6/App.axaml.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using Avalonia;
-using Avalonia.Markup.Xaml;
-using GuiNet6.ViewModels;
-using GuiNet6.Views;
-using Prism.DryIoc;
-using Prism.Ioc;
-using Prism.Modularity;
-using Prism.Regions;
-
-namespace GuiNet6;
-
-public class App : PrismApplication
-{
- /// App entry point.
- public override void Initialize()
- {
- AvaloniaXamlLoader.Load(this);
- base.Initialize();
- }
-
- /// Prism Module Registration.
- ///
- /// Module Catalog.
- protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog)
- {
- base.ConfigureModuleCatalog(moduleCatalog);
-
- // Wire-up modules for Region Manager
- //// moduleCatalog.AddModule();
- }
-
- /// User interface entry point, called after Register and ConfigureModules.
- /// Startup View.
- protected override IAvaloniaObject CreateShell()
- {
- return this.Container.Resolve();
- }
-
- /// Called after Initialize.
- protected override void OnInitialized()
- {
- // Register Views to Region it will appear in. Don't register them in the ViewModel.
- var regionManager = Container.Resolve();
- regionManager.RegisterViewWithRegion(RegionNames.ContentRegion, typeof(DashboardView));
- regionManager.RegisterViewWithRegion(RegionNames.SidebarRegion, typeof(SidebarView));
- }
-
- /// Register views and Services.
- /// IOC Container.
- protected override void RegisterTypes(IContainerRegistry containerRegistry)
- {
- // Services
- // ...
-
- // Views - Generic views
- containerRegistry.Register();
- containerRegistry.Register();
-
- // Views - Region Navigation
- containerRegistry.RegisterForNavigation();
- containerRegistry.RegisterForNavigation();
- }
-}
diff --git a/sandbox/GuiNet6/GuiNet6.csproj b/sandbox/GuiNet6/GuiNet6.csproj
deleted file mode 100644
index 9df2e77..0000000
--- a/sandbox/GuiNet6/GuiNet6.csproj
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
- WinExe
- net6.0
- enable
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ShellWindow.axaml
-
-
-
diff --git a/sandbox/GuiNet6/RegionNames.cs b/sandbox/GuiNet6/RegionNames.cs
deleted file mode 100644
index 3a323ed..0000000
--- a/sandbox/GuiNet6/RegionNames.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace GuiNet6;
-
-public static class RegionNames
-{
- /// Main Window's content region
- public const string ContentRegion = "ContentRegion";
-
- /// Main Window's Footer Status Bar.
- public const string FooterRegion = "FooterRegion";
-
- /// Main Window's right side bar.
- public const string RightSidebarRegion = "RightSidebarRegion";
-
- /// Main Window's side bar.
- public const string SidebarRegion = "SidebarRegion";
-}
diff --git a/sandbox/GuiNet6/ViewModels/DashboardViewModel.cs b/sandbox/GuiNet6/ViewModels/DashboardViewModel.cs
deleted file mode 100644
index 353e370..0000000
--- a/sandbox/GuiNet6/ViewModels/DashboardViewModel.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Microsoft.CodeAnalysis;
-using Prism.Commands;
-
-namespace GuiNet6.ViewModels;
-
-public class DashboardViewModel : ViewModelBase
-{
- public DashboardViewModel()
- {
- Title = "Dashboard View!";
- }
-
- public DelegateCommand CmdBreakPoint => new(() =>
- {
- // Force a breakpoint
- System.Diagnostics.Debug.WriteLine("Breakpoint triggering");
- System.Diagnostics.Debugger.Break();
- });
-}
diff --git a/sandbox/GuiNet6/ViewModels/ShellWindowViewModel.cs b/sandbox/GuiNet6/ViewModels/ShellWindowViewModel.cs
deleted file mode 100644
index 557358f..0000000
--- a/sandbox/GuiNet6/ViewModels/ShellWindowViewModel.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace GuiNet6.ViewModels;
-
-public class ShellWindowViewModel : ViewModelBase
-{
- public ShellWindowViewModel()
- {
- Title = "Sample Prism.Avalonia - Navigation";
- }
-}
diff --git a/sandbox/GuiNet6/ViewModels/SidebarViewModel.cs b/sandbox/GuiNet6/ViewModels/SidebarViewModel.cs
deleted file mode 100644
index 2f6beca..0000000
--- a/sandbox/GuiNet6/ViewModels/SidebarViewModel.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using GuiNet6.Views;
-using Prism.Commands;
-using Prism.Events;
-using Prism.Regions;
-
-namespace GuiNet6.ViewModels;
-
-public class SidebarViewModel : ViewModelBase
-{
- private IEventAggregator _eventAggregator;
- private IRegionManager _regionManager;
-
- public SidebarViewModel(IRegionManager regionManager, IEventAggregator ea)
- {
- _regionManager = regionManager;
- _eventAggregator = ea;
-
- Title = "Navigation";
- }
-
- public DelegateCommand CmdDashboard => new(() =>
- {
- _regionManager.RequestNavigate(RegionNames.ContentRegion, nameof(DashboardView));
- });
-
- public DelegateCommand CmdSettings => new(() =>
- {
- _regionManager.RequestNavigate(RegionNames.ContentRegion, nameof(SettingsView));
- });
-}
diff --git a/sandbox/GuiNet6/ViewModels/ViewModelBase.cs b/sandbox/GuiNet6/ViewModels/ViewModelBase.cs
deleted file mode 100644
index f72b5b6..0000000
--- a/sandbox/GuiNet6/ViewModels/ViewModelBase.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using System;
-using System.Linq.Expressions;
-using Prism.Mvvm;
-using Prism.Regions;
-
-namespace GuiNet6.ViewModels;
-
-public class ViewModelBase : BindableBase, INavigationAware
-{
- private string _title = string.Empty;
-
- /// Gets or sets the title of the View.
- public string Title
- {
- get => _title;
- set => SetProperty(ref _title, value);
- }
-
- ///
- /// Called to determine if this instance can handle the navigation request.
- /// Don't call this directly, use .
- ///
- /// The navigation context.
- /// if this instance accepts the navigation request; otherwise, .
- public virtual bool IsNavigationTarget(NavigationContext navigationContext)
- {
- // Auto-allow navigation
- return OnNavigatingTo(navigationContext);
- }
-
- /// Called when the implementer is being navigated away from.
- /// The navigation context.
- public virtual void OnNavigatedFrom(NavigationContext navigationContext)
- {
- }
-
- /// Called when the implementer has been navigated to.
- /// The navigation context.
- public virtual void OnNavigatedTo(NavigationContext navigationContext)
- {
- }
-
- /// Navigation validation checker.
- /// Override for Prism 7.2's IsNavigationTarget.
- /// The navigation context.
- /// if this instance accepts the navigation request; otherwise, .
- public virtual bool OnNavigatingTo(NavigationContext navigationContext)
- {
- return true;
- }
-
- // Note from Prism.Mvvm.BindableBase
- [Obsolete("Please use RaisePropertyChanged(nameof(PropertyName)) from Prism.Mvvm.BindableBase instead. Expressions are slower, and the new nameof feature eliminates the magic strings.")]
- protected void RaisePropertyChanged(Expression> propertyExpression)
- {
- var propertyName = PropertySupport.ExtractPropertyName(propertyExpression);
- RaisePropertyChanged(propertyName);
- }
-}
diff --git a/sandbox/GuiNet6/nuget.config b/sandbox/GuiNet6/nuget.config
deleted file mode 100644
index 6c273ab..0000000
--- a/sandbox/GuiNet6/nuget.config
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/.editorconfig b/src/.editorconfig
index 1c08561..0248ba7 100644
--- a/src/.editorconfig
+++ b/src/.editorconfig
@@ -333,6 +333,14 @@ dotnet_diagnostic.CS0618.severity = error
# CS1591: Missing XML comment for publicly visible type or member
dotnet_diagnostic.CS1591.severity = silent
+## VS Threading Analyzers
+# VSTHRD010: Accessing X should only be done on the main thread. Suppressed project-wide:
+# Logger.Output/Debug write via IVsOutputWindowPane.OutputStringThreadSafe, which is
+# documented safe to call off the UI thread, but the analyzer taints every method in the
+# call graph (SshTool/RemoteDebugger/LaunchBuilder's async SSH/deploy code) as if it were
+# UI-thread-only, with no way to mark the actual call as verified safe.
+dotnet_diagnostic.VSTHRD010.severity = none
+
## StyleCop.Analyzers
# SA1000: Keywords should be spaced correctly
dotnet_diagnostic.SA1000.severity = error
diff --git a/src/LinuxDebugger.sln b/src/LinuxDebugger.sln
index 823c99e..c462ca1 100644
--- a/src/LinuxDebugger.sln
+++ b/src/LinuxDebugger.sln
@@ -13,9 +13,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{42E06A3F-6EF1-49EC-910E-A89C15BC6E91}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GuiNet6", "..\sandbox\GuiNet6\GuiNet6.csproj", "{2F01BAAF-DF1E-406C-A46D-127896C203F1}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gui", "..\sandbox\Gui\Gui.csproj", "{2F01BAAF-DF1E-406C-A46D-127896C203F1}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleNet6", "..\sandbox\ConsoleNet6\ConsoleNet6.csproj", "{D108A8EB-E4CD-4A84-A92B-DAD4DB6CA983}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Console", "..\sandbox\Console\Console.csproj", "{D108A8EB-E4CD-4A84-A92B-DAD4DB6CA983}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/src/VsLinuxDebugger/Commands.Impl.cs b/src/VsLinuxDebugger/Commands.Impl.cs
index a323135..7927133 100644
--- a/src/VsLinuxDebugger/Commands.Impl.cs
+++ b/src/VsLinuxDebugger/Commands.Impl.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
@@ -16,7 +17,6 @@ private sealed class CommandIds
{
public const int CmdBuildDeployOnly = 0x1001;
public const int CmdBuildDeployDebug = 0x1002;
- public const int CmdBuildDeployLaunch = 0x1006;
public const int CmdDebugOnly = 0x1003;
////public const int CmdPublishOnly = 0x1006;
@@ -24,12 +24,17 @@ private sealed class CommandIds
public const int CmdShowLog = 0x1004;
public const int CmdShowSettings = 0x1005;
+ public const int CmdStop = 0x1006;
public const int LinuxRemoteMainMenu = 0x1000;
public const int RemoteMainMenuGroupLevel1 = 0x1100;
public const int RemoteMainMenuGroupLevel2 = 0x1200;
}
+ /// Non-null while a build/deploy/debug operation is in progress; cancelled by
+ /// .
+ private static CancellationTokenSource _runningOperation;
+
/////// Override standard button text with.
/////// Command Id.
/////// Text to display.
@@ -40,7 +45,6 @@ private sealed class CommandIds
//// case CommandIds.CmdBuildDeployOnly: return "Build and Deploy";
//// case CommandIds.CmdBuildDeployDebug: return "Build, Deploy and Debug";
////
- //// case CommandIds.CmdBuildDeployLaunch: return "Build, Deploy and Launch";
//// case CommandIds.CmdDebugOnly: return "Debug Only";
//// ////case CommandIds.CmdPublishOnly: return "Publish Only";
//// ////case CommandIds.CmdPublishDebug: return "Publish and Debug";
@@ -57,13 +61,18 @@ private void CreateVsMenu(OleMenuCommandService cmd)
{
AddMenuItem(cmd, CommandIds.CmdBuildDeployOnly, SetMenuTextAndVisibility, OnBuildDeployAsync);
AddMenuItem(cmd, CommandIds.CmdBuildDeployDebug, SetMenuTextAndVisibility, OnBuildDeployDebugAsync);
- AddMenuItem(cmd, CommandIds.CmdBuildDeployLaunch, SetMenuTextAndVisibility, OnBuildDeployLaunchAsync);
////AddMenuItem(cmd, CommandIds.CmdPublishDebug, SetMenuTextAndVisibility, OnPublishDebugAsyc);
AddMenuItem(cmd, CommandIds.CmdDebugOnly, SetMenuTextAndVisibility, OnDebugOnlyAsync);
AddMenuItem(cmd, CommandIds.CmdShowLog, SetMenuTextAndVisibility, OnShowLog);
AddMenuItem(cmd, CommandIds.CmdShowSettings, SetMenuTextAndVisibility, OnShowSettingsAsync);
+
+ // OleMenuCommand.Enabled defaults to true and is only recomputed by
+ // BeforeQueryStatus once the menu is actually opened/queried -- without this, Stop
+ // shows enabled from VS startup until the first time the menu is touched.
+ var stopCmd = AddMenuItem(cmd, CommandIds.CmdStop, SetMenuTextAndVisibility, OnStop);
+ stopCmd.Enabled = false;
}
private async Task ExecuteBuildAsync(BuildOptions buildOptions)
@@ -72,25 +81,38 @@ private async Task ExecuteBuildAsync(BuildOptions buildOptions)
var success = true;
- var options = ToUserOptions();
- using (var dbg = new RemoteDebugger(options))
+ using (var cts = new CancellationTokenSource())
{
- if (!dbg.IsProjectValid())
- {
- Logger.Output("No C# startup project/solution loaded.");
- success = false;
- }
+ _runningOperation = cts;
- if (success && !await dbg.BeginAsync(buildOptions))
+ var options = ToUserOptions();
+ using (var dbg = new RemoteDebugger(options))
{
- Logger.Output("Failed to perform actions.");
- success = false;
+ if (!dbg.IsProjectValid())
+ {
+ Logger.Output("No C# startup project/solution loaded.");
+ success = false;
+ }
+
+ if (success && !await dbg.BeginAsync(buildOptions, cts.Token))
+ {
+ if (!cts.IsCancellationRequested)
+ Logger.Output("Failed to perform actions.");
+ success = false;
+ }
}
+
+ _runningOperation = null;
}
return success;
}
+ private void OnStop(object sender, EventArgs e)
+ {
+ _runningOperation?.Cancel();
+ }
+
private async void OnBuildDeployAsync(object sender, EventArgs e)
{
await ExecuteBuildAsync(BuildOptions.Build | BuildOptions.Deploy);
@@ -101,14 +123,10 @@ private async void OnBuildDeployDebugAsync(object sender, EventArgs e)
await ExecuteBuildAsync(BuildOptions.Build | BuildOptions.Deploy | BuildOptions.Debug);
}
- private async void OnBuildDeployLaunchAsync(object sender, EventArgs e)
- {
- await ExecuteBuildAsync(BuildOptions.Build | BuildOptions.Deploy | BuildOptions.Launch);
- }
-
private async void OnDebugOnlyAsync(object sender, EventArgs e)
{
- await ExecuteBuildAsync(BuildOptions.Build | BuildOptions.Debug);
+ // No Build, no Deploy/Publish: just attach to what's already running/deployed.
+ await ExecuteBuildAsync(BuildOptions.Debug);
}
private void OnShowLog(object sender, EventArgs e)
@@ -128,7 +146,7 @@ private async void OnShowSettingsAsync(object sender, EventArgs e)
await Task.Yield();
- Instance._package.ShowOptionPage(typeof(OptionsPage));
+ Instance._package.ShowOptionPage(typeof(RemoteHostOptionsPage));
}
private void SetMenuTextAndVisibility(object sender, EventArgs e)
@@ -142,16 +160,20 @@ private void SetMenuTextAndVisibility(object sender, EventArgs e)
//// cmd.Text = $"{GetMenuText(cmd.CommandID.ID)} ({settings.HostIp})";
//// cmd.Enabled = _extension.IsStartupProjectAvailable();
- if (cmd.CommandID.ID == CommandIds.CmdShowLog
- || cmd.CommandID.ID == CommandIds.CmdDebugOnly
- ////|| cmd.CommandID.ID == CommandIds.CmdShowSettings
- || cmd.CommandID.ID == CommandIds.CmdBuildDeployLaunch)
+ var isRunning = _runningOperation != null;
+
+ //// || cmd.CommandID.ID == CommandIds.CmdShowSettings
+ if (cmd.CommandID.ID == CommandIds.CmdShowLog)
{
cmd.Enabled = false;
}
+ else if (cmd.CommandID.ID == CommandIds.CmdStop)
+ {
+ cmd.Enabled = isRunning;
+ }
else
{
- cmd.Enabled = true;
+ cmd.Enabled = !isRunning;
}
}
}
@@ -162,30 +184,40 @@ private UserOptions ToUserOptions()
return new UserOptions
{
- DeleteLaunchJsonAfterBuild = VsixPackage.VsixOptions.DeleteLaunchJsonAfterBuild,
-
- HostIp = VsixPackage.VsixOptions.HostIp,
- HostPort = VsixPackage.VsixOptions.HostPort,
-
- LocalPLinkPath = VsixPackage.VsixOptions.PLinkPath,
- LocalSwitchLinuxDbgOutput = VsixPackage.VsixOptions.SwitchLinuxDbgOutput,
-
- RemoteDebugDisplayGui = VsixPackage.VsixOptions.RemoteDebugDisplayGui,
- RemoteDebugDisplayNumber = VsixPackage.VsixOptions.RemoteDebugDisplayNumber,
- RemoteDeployBasePath = VsixPackage.VsixOptions.RemoteDeployBasePath,
- RemoteDotNetPath = VsixPackage.VsixOptions.RemoteDotNetPath,
- RemoteVsDbgBasePath = VsixPackage.VsixOptions.RemoteVsDbgRootPath,
-
- UseCommandLineArgs = VsixPackage.VsixOptions.UseCommandLineArgs,
+ DeleteLaunchJsonAfterBuild = VsixPackage.LocalOptions.DeleteLaunchJsonAfterBuild,
+
+ HostIp = VsixPackage.RemoteHostOptions.HostIp,
+ HostPort = VsixPackage.RemoteHostOptions.HostPort,
+
+ LocalPLinkPath = VsixPackage.LocalOptions.PLinkPath,
+ LocalSwitchLinuxDbgOutput = VsixPackage.LocalOptions.SwitchLinuxDbgOutput,
+ ForceKillOnStop = VsixPackage.LocalOptions.ForceKillOnStop,
+
+ RemoteDebugDisplayGui = VsixPackage.RemoteLaunchOptions.RemoteDebugDisplayGui,
+ RemoteDebugDisplayNumber = VsixPackage.RemoteLaunchOptions.RemoteDebugDisplayNumber,
+ RemoteDeployBasePath = VsixPackage.RemoteDebuggerOptions.RemoteDeployBasePath,
+ RemoteEnvironmentVariables = VsixPackage.RemoteLaunchOptions.RemoteEnvironmentVariables,
+ RemotePreDeployCommands = VsixPackage.RemoteLaunchOptions.RemotePreDeployCommands,
+ RemotePostDeployCommands = VsixPackage.RemoteLaunchOptions.RemotePostDeployCommands,
+ AttachToRunningProcess = VsixPackage.RemoteLaunchOptions.AttachToRunningProcess,
+ RemotePidCommand = VsixPackage.RemoteLaunchOptions.RemotePidCommand,
+ RemoteVsDbgBasePath = VsixPackage.RemoteDebuggerOptions.RemoteVsDbgRootPath,
+
+ SudoCommand = VsixPackage.RemoteLaunchOptions.SudoCommand,
+ UseSudoForDebugger = VsixPackage.RemoteLaunchOptions.UseSudoForDebugger,
+
+ UseSelfContainedDeployment = VsixPackage.RemoteDebuggerOptions.UseSelfContainedDeployment,
+ RemoteRuntimeIdentifier = VsixPackage.RemoteDebuggerOptions.RemoteRuntimeIdentifier,
//// UsePublish = Settings.UsePublish,
- UserPrivateKeyEnabled = VsixPackage.VsixOptions.UserPrivateKeyEnabled,
- UserPrivateKeyPath = VsixPackage.VsixOptions.UserPrivateKeyPath,
- UserPrivateKeyPassword = VsixPackage.VsixOptions.UserPrivateKeyPassword,
- UserName = VsixPackage.VsixOptions.UserName,
- UserPass = VsixPackage.VsixOptions.UserPass,
- UserGroupName = VsixPackage.VsixOptions.UserGroupName,
- UseSSHExeEnabled = VsixPackage.VsixOptions.UseSSHExeEnabled
+ UserPrivateKeyEnabled = VsixPackage.RemoteCredentialsOptions.UserPrivateKeyEnabled,
+ UserPrivateKeyPath = VsixPackage.RemoteCredentialsOptions.UserPrivateKeyPath,
+ UserPrivateKeyPassword = VsixPackage.RemoteCredentialsOptions.UserPrivateKeyPassword,
+ UserCertificatePath = VsixPackage.RemoteCredentialsOptions.UserCertificatePath,
+ UserName = VsixPackage.RemoteCredentialsOptions.UserName,
+ UserPass = VsixPackage.RemoteCredentialsOptions.UserPass,
+ UserGroupName = VsixPackage.RemoteHostOptions.UserGroupName,
+ UseSSHExeEnabled = VsixPackage.LocalOptions.UseSSHExeEnabled
};
}
}
diff --git a/src/VsLinuxDebugger/Core/BuildOptions.cs b/src/VsLinuxDebugger/Core/BuildOptions.cs
index 34e5697..482160b 100644
--- a/src/VsLinuxDebugger/Core/BuildOptions.cs
+++ b/src/VsLinuxDebugger/Core/BuildOptions.cs
@@ -1,12 +1,20 @@
-namespace VsLinuxDebugger.Core
+using System;
+
+namespace VsLinuxDebugger.Core
{
+ /// Combined via bitwise OR (i.e. `Build | Deploy | Debug`) and tested with
+ /// throughout RemoteDebugger -- values must stay distinct
+ /// powers of two, or combinations silently collide (i.e. plain sequential values made
+ /// `Debug` (3) already contain the `Deploy` (1) bit, so "Attach Only" (Debug alone)
+ /// was silently treated as if Deploy/Publish had been requested too).
+ [Flags]
public enum BuildOptions
{
- Build,
- Deploy,
- Publish,
- Debug,
+ Build = 1 << 0,
+ Deploy = 1 << 1,
+ Publish = 1 << 2,
+ Debug = 1 << 3,
/// Launches application with `DISPLAY:=0`
- Launch,
+ Launch = 1 << 4,
}
}
diff --git a/src/VsLinuxDebugger/Core/Constants.cs b/src/VsLinuxDebugger/Core/Constants.cs
index 7f553fa..7de0705 100644
--- a/src/VsLinuxDebugger/Core/Constants.cs
+++ b/src/VsLinuxDebugger/Core/Constants.cs
@@ -10,6 +10,11 @@ public static class Constants
public const string DefaultVsdbgBasePath = "~/.vs-debugger";
public const string LaunchJson = "launch.json";
+ /// Default command used to elevate the debugger when UseSudoForDebugger is enabled.
+ /// No '-E' (preserve environment): many sudoers configs don't allow it, causing 'sorry, you are
+ /// not allowed to preserve the environment'. Users who need it can still add it themselves.
+ public const string DefaultSudoCommand = "sudo -n";
+
public const string PackageTarGz = "vsldBuildContents.tar.gz";
}
}
diff --git a/src/VsLinuxDebugger/Core/LaunchBuilder.cs b/src/VsLinuxDebugger/Core/LaunchBuilder.cs
index 8551ea4..c254747 100644
--- a/src/VsLinuxDebugger/Core/LaunchBuilder.cs
+++ b/src/VsLinuxDebugger/Core/LaunchBuilder.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text.Json;
using EnvDTE;
using EnvDTE80;
@@ -29,8 +31,14 @@ public LaunchBuilder(DTE2 dte, Project dteProject, UserOptions userOptions)
SolutionDirPath = Path.GetDirectoryName(dte.Solution.FullName);
OutputDirName = dteProject.ConfigurationManager.ActiveConfiguration.Properties.Item("OutputPath").Value.ToString();
OutputDirFullPath = Path.Combine(Path.GetDirectoryName(dteProject.FullName), OutputDirName);
+ PublishDirFullPath = Path.Combine(Path.GetTempPath(), "VsLinuxDebuggerPublish", ProjectName);
}
+ /// Folder `dotnet publish` writes to for a self-contained deployment. A temp folder,
+ /// not under the project's own `bin`/`obj`, so it never collides with the IDE's own build
+ /// output and is safe to wipe before each publish.
+ public string PublishDirFullPath { get; set; }
+
/// Project assembly name. I.E. "ConsoleApp1"
public string AssemblyName { get; set; }
@@ -54,13 +62,13 @@ public LaunchBuilder(DTE2 dte, Project dteProject, UserOptions userOptions)
/// Project name (not always the same as AssemblyName). I.E. "Console App1"
public string ProjectName { get; set; }
- /// Full path to the remote assembly. (i.e. `/home/USER/VLSDbg/Proj/ConsoleApp1.dll`)
- public string RemoteDeployAssemblyFilePath => LinuxPath.Combine(RemoteDeployProjectFolder, $"{AssemblyName}.dll");
-
- /// Folder of our remote assembly. (i.e. `/home/USER/VLSDbg/Proj`)
- public string RemoteDeployProjectFolder => LinuxPath.Combine(_opts.RemoteDeployBasePath, ProjectName);
+ /// Full path to the deployed native executable (always produced by `dotnet
+ /// publish`, even framework-dependent). (i.e. `/home/USER/VLSDbg/ConsoleApp1`)
+ public string RemoteDeployExecutableFilePath => LinuxPath.Combine(RemoteDeployProjectFolder, AssemblyName);
- public string RemoteDotNetPath => _opts.RemoteDotNetPath;
+ /// Folder files are deployed to. This is the configured deploy path itself
+ /// (i.e. `/home/USER/VLSDbg`) -- no per-project subfolder is added.
+ public string RemoteDeployProjectFolder => _opts.RemoteDeployBasePath;
public string RemoteHostIp => _opts.HostIp;
@@ -84,17 +92,41 @@ public string GenerateLaunchJson(bool vsdbgLogging = false)
(adapter, adapterArgs) = GetAdapter(vsdbgLogging);
+ // Always deployed via `dotnet publish`, which generates a native apphost executable
+ // even when framework-dependent -- so launch it directly, never via `dotnet `.
var obj = new Launch(
- RemoteDotNetPath,
- $"{AssemblyName}.dll", /// RemoteDeployAppPath,
+ RemoteDeployExecutableFilePath,
+ new string[0],
RemoteDeployProjectFolder,
- default,
+ ParseEnvironmentVariables(_opts.RemoteEnvironmentVariables),
false)
{
Adapter = adapter,
AdapterArgs = adapterArgs,
};
+ return WriteLaunchJson(obj);
+ }
+
+ /// Generates a `launch.json` that attaches to an already-running remote process
+ /// (i.e. one managed by a systemd service), instead of launching a new one.
+ /// Remote process ID to attach to.
+ /// Returns the local path to the file.
+ public string GenerateAttachLaunchJson(int processId, bool vsdbgLogging = false)
+ {
+ string adapter, adapterArgs;
+
+ (adapter, adapterArgs) = GetAdapter(vsdbgLogging);
+
+ var obj = Launch.CreateAttach(processId);
+ obj.Adapter = adapter;
+ obj.AdapterArgs = adapterArgs;
+
+ return WriteLaunchJson(obj);
+ }
+
+ private string WriteLaunchJson(Launch obj)
+ {
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -157,30 +189,36 @@ public string GenerateLaunchJson(bool vsdbgLogging = false)
// Adapter Arguments:
// NOTE:
- // 1. SSH Private Key ("-i PPK") fails with PLINK. Must use manual password until this is resolved.
+ // 1. SSH Private Key ("-i PPK") fails with PLINK; PuTTY/plink requires its own
+ // ".ppk" key format, not an OpenSSH-format private key. Use ssh.exe (below) for
+ // OpenSSH-format keys, or convert the key to PPK for use with PLINK.
// 2. Strict Host Key Checking is disabled by default; this doesn't need set.
//
// REF: https://linuxhint.com/ssh-stricthostkeychecking/
- // $"-i \"{_opts.UserPrivateKeyPath}\" -o \"StrictHostKeyChecking no\" {RemoteUserName}@{RemoteHostIp} {_opts.RemoteVsDbgPath} --interpreter=vscode {vsdbgLogPath}")
- //
- //// var strictKeyChecking = " -o \"StrictHostKeyChecking no\"";
- ////
- ////var sshPassword = !_opts.UserPrivateKeyEnabled
- //// ? $"-pw {RemoteUserPass}"
- //// : $"-i \"{_opts.UserPrivateKeyPath}{strictKeyChecking}\"";
string sshPassword = "";
- if(_opts.UseSSHExeEnabled)
+ if (_opts.UseSSHExeEnabled)
{
- sshPassword = ""; //nothing to do, we assume that c:\users\[user]\.ssh\id_rsa exists
+ // ssh.exe (OpenSSH) supports "-i " directly, and finds a matching
+ // "-cert.pub" on its own; only override with -oCertificateFile if the
+ // user pointed us at a certificate somewhere else.
+ if (_opts.UserPrivateKeyEnabled && !string.IsNullOrEmpty(_opts.UserPrivateKeyPath))
+ {
+ sshPassword = $"-i \"{_opts.UserPrivateKeyPath}\"";
+ if (!string.IsNullOrEmpty(_opts.UserCertificatePath))
+ sshPassword += $" -oCertificateFile=\"{_opts.UserCertificatePath}\"";
+ }
+ else
+ {
+ sshPassword = ""; // Nothing to do; ssh.exe falls back to its own default key discovery (i.e. ~/.ssh/id_rsa).
+ }
}
else
{
sshPassword = $"-pw {RemoteUserPass}";
}
- // TODO: Figure out why "-i " isn't working.
- if (string.IsNullOrEmpty(RemoteUserPass))
+ if (!_opts.UseSSHExeEnabled && string.IsNullOrEmpty(RemoteUserPass))
Logger.Output("You must provide a User Password to debug.");
string adapter = plinkPath;
@@ -194,19 +232,56 @@ public string GenerateLaunchJson(bool vsdbgLogging = false)
"DISPLAY=:0";
}
+ // Optionally elevate the debugger itself, for debuggees running with capabilities
+ // (ambient/file capabilities, setuid, etc.) that VSDBG must match in order to attach.
+ var remoteVsDbgCommand = _opts.UseSudoForDebugger
+ ? $"{_opts.SudoCommand} {_opts.RemoteVsDbgFullPath}"
+ : _opts.RemoteVsDbgFullPath;
+
if (_opts.UseSSHExeEnabled)
{
- adapterArgs = $"{sshPassword} {sshEndpoint} -T {displayAdapter} {_opts.RemoteVsDbgFullPath} {vsdbgLogPath}";
- //// adapterArgs = $"-ssh {sshPassword} {sshEndpoint} -batch -T {RemoteVsDbgFullPath} --interpreter=vscode {vsdbgLogPath}";
+ adapterArgs = $"{sshPassword} {sshEndpoint} -T {displayAdapter} {remoteVsDbgCommand} {vsdbgLogPath}";
+ //// adapterArgs = $"-ssh {sshPassword} {sshEndpoint} -batch -T {remoteVsDbgCommand} --interpreter=vscode {vsdbgLogPath}";
}
else
{
- adapterArgs= $"-ssh {sshPassword} {sshEndpoint} -T {displayAdapter} {_opts.RemoteVsDbgFullPath} {vsdbgLogPath}";
+ adapterArgs= $"-ssh {sshPassword} {sshEndpoint} -T {displayAdapter} {remoteVsDbgCommand} {vsdbgLogPath}";
}
return (adapter, adapterArgs);
}
+ /// Parses `KEY=VALUE` pairs (one per line) into a dictionary for `launch.json`'s `env`.
+ /// Newline-separated `KEY=VALUE` pairs.
+ /// Dictionary of environment variables, or null if none were provided.
+ private Dictionary ParseEnvironmentVariables(string rawEnvVariables)
+ {
+ if (string.IsNullOrWhiteSpace(rawEnvVariables))
+ return null;
+
+ var env = new Dictionary();
+
+ foreach (var line in rawEnvVariables.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var trimmedLine = line.Trim();
+ if (trimmedLine.Length == 0 || trimmedLine.StartsWith("#"))
+ continue;
+
+ var separatorIndex = trimmedLine.IndexOf('=');
+ if (separatorIndex <= 0)
+ {
+ Logger.Output($"Ignoring malformed environment variable line: '{trimmedLine}'");
+ continue;
+ }
+
+ var key = trimmedLine.Substring(0, separatorIndex).Trim();
+ var value = trimmedLine.Substring(separatorIndex + 1).Trim();
+ env[key] = value;
+ }
+
+ return env.Count > 0 ? env : null;
+ }
+
/// Attempt to get the extension's local directory.
/// Path of this VSIX or empty string.
private string GetExtensionDirectory()
diff --git a/src/VsLinuxDebugger/Core/Remote/Configuration.cs b/src/VsLinuxDebugger/Core/Remote/Configuration.cs
index 13b0825..c086252 100644
--- a/src/VsLinuxDebugger/Core/Remote/Configuration.cs
+++ b/src/VsLinuxDebugger/Core/Remote/Configuration.cs
@@ -1,4 +1,5 @@
// is "attach".
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int? ProcessId { get; set; }
/// What kind of console to use. For example, 'internalConsole', 'integratedTerminal', or 'externalTerminal'.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
@@ -42,7 +47,7 @@ public class Configuration
/// Environment variables (the value null can be used to "undefine" a variable).
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
- public string Env { get; set; }
+ public Dictionary Env { get; set; }
//// ---- TESTING PHASE BELOW ----
////
diff --git a/src/VsLinuxDebugger/Core/Remote/Launch.cs b/src/VsLinuxDebugger/Core/Remote/Launch.cs
index 41cda90..658b414 100644
--- a/src/VsLinuxDebugger/Core/Remote/Launch.cs
+++ b/src/VsLinuxDebugger/Core/Remote/Launch.cs
@@ -8,7 +8,7 @@ public class Launch
private string _remoteDotNetPath;
private string[] _args;
private string _remoteOutputFolder;
- private string _environmentVariables;
+ private Dictionary _environmentVariables;
private bool _stopAtEntry;
/// Launch JSON class
@@ -16,14 +16,20 @@ public class Launch
/// Name of app(.dll) or full path on remote machine.
/// Working directory (CWD) where app resides.
/// Custom environment variables.
- public Launch(string dotNetPath, string remoteAppFileName, string remoteOutputFolder, string envVariables = default, bool stopAtEntry = false)
+ public Launch(string dotNetPath, string remoteAppFileName, string remoteOutputFolder, Dictionary envVariables = default, bool stopAtEntry = false)
+ : this(dotNetPath, new[] { remoteAppFileName }, remoteOutputFolder, envVariables, stopAtEntry)
{
- ////string appPath = LinuxPath.Combine(remoteDebugFolder, $"{appName}.dll");
- string appToLaunch = remoteAppFileName;
+ }
- System.IO.Path.Combine("");
- _remoteDotNetPath = dotNetPath;
- _args = new string[] { appToLaunch };
+ /// Launch JSON class
+ /// Executable to launch on the remote machine (i.e. `dotnet`, or the app itself for a self-contained deployment).
+ /// Arguments passed to (i.e. the assembly path for a framework-dependent deployment).
+ /// Working directory (CWD) where app resides.
+ /// Custom environment variables.
+ public Launch(string program, string[] args, string remoteOutputFolder, Dictionary envVariables = default, bool stopAtEntry = false)
+ {
+ _remoteDotNetPath = program;
+ _args = args;
_remoteOutputFolder = remoteOutputFolder;
_environmentVariables = envVariables;
_stopAtEntry = stopAtEntry;
@@ -41,6 +47,19 @@ public Launch(string dotNetPath, string remoteAppFileName, string remoteOutputFo
};
}
+ /// Creates a launch.json that attaches to an already-running remote process,
+ /// instead of launching a new one. Use this for a debuggee managed by something else
+ /// (i.e. a systemd service), so the debugger attaches to that exact process rather than
+ /// starting a second, unmanaged instance alongside it.
+ /// Remote process ID to attach to.
+ public static Launch CreateAttach(int processId)
+ {
+ var launch = new Launch(program: null, args: null, remoteOutputFolder: null);
+ launch.Configurations[0].Request = "attach";
+ launch.Configurations[0].ProcessId = processId;
+ return launch;
+ }
+
public string Version => "0.2.0";
public string Adapter { get; set; }
diff --git a/src/VsLinuxDebugger/Core/RemoteDebugger.cs b/src/VsLinuxDebugger/Core/RemoteDebugger.cs
index f3c1209..11d8b0d 100644
--- a/src/VsLinuxDebugger/Core/RemoteDebugger.cs
+++ b/src/VsLinuxDebugger/Core/RemoteDebugger.cs
@@ -1,12 +1,15 @@
using System;
using System.IO;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using EnvDTE;
using EnvDTE80;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using VsLinuxDebugger.Extensions;
+using Process = System.Diagnostics.Process;
+using ProcessStartInfo = System.Diagnostics.ProcessStartInfo;
namespace VsLinuxDebugger.Core
{
@@ -39,8 +42,12 @@ public RemoteDebugger(UserOptions options)
/// Perform operation.
/// Build options.
+ /// Cancelled when the user hits Stop. Checked between each
+ /// major step; a SSH command already in flight is instead let to finish unless
+ /// is enabled, in which case the connection is
+ /// torn down immediately, mid-command.
/// True on success.
- public async Task BeginAsync(BuildOptions buildOptions)
+ public async Task BeginAsync(BuildOptions buildOptions, CancellationToken cancellationToken = default)
{
try
{
@@ -51,11 +58,22 @@ public async Task BeginAsync(BuildOptions buildOptions)
return false;
}
+ cancellationToken.ThrowIfCancellationRequested();
+
if (buildOptions.HasFlag(BuildOptions.Build))
{
BuildBegin();
- await _buildTask.Task;
+ using (cancellationToken.Register(() => CancelBuild()))
+ {
+#pragma warning disable VSTHRD003 // _buildTask is a TaskCompletionSource completed by a
+ // DTE build-event callback (BuildEvents_OnBuildDone), not a cold task started
+ // elsewhere; awaiting it here cannot deadlock.
+ await _buildTask.Task;
+#pragma warning restore VSTHRD003
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
// Work completed
if (!_buildSuccessful)
@@ -65,9 +83,25 @@ public async Task BeginAsync(BuildOptions buildOptions)
}
}
+ if (buildOptions.HasFlag(BuildOptions.Deploy))
+ {
+ if (!await PublishAsync(cancellationToken))
+ {
+ Logger.Output("Publish was not successful.");
+ return false;
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
var remoteInfo = GetRemoteConnectionInfo();
using (var ssh = new SshTool(remoteInfo))
+ using (cancellationToken.Register(() =>
+ {
+ if (_options.ForceKillOnStop)
+ ssh.Dispose();
+ }))
{
var success = await ssh.ConnectAsync();
if (!success)
@@ -79,44 +113,81 @@ public async Task BeginAsync(BuildOptions buildOptions)
var vsDbgFolder = LinuxPath.Combine(_options.RemoteVsDbgBasePath, Constants.VS2022);
await ssh.TryInstallVsDbgAsync(vsDbgFolder);
- await ssh.MakeDeploymentFolderAsync(_options.RemoteDeployBasePath);
- await ssh.CleanFolderAsync(_launchBuilder.RemoteDeployProjectFolder);
+
+ cancellationToken.ThrowIfCancellationRequested();
if (buildOptions.HasFlag(BuildOptions.Deploy))
{
- await ssh.UploadFilesAsync(_launchBuilder.OutputDirFullPath, _launchBuilder.RemoteDeployProjectFolder);
+ // Only touch the deployment folder (and run the pre/post-deploy commands) when
+ // we're actually about to overwrite it -- doing this unconditionally used to
+ // delete the executable backing an already-running attached process on every
+ // "Attach Only" run, and re-run the post-deploy commands even when unchanged.
+ await ssh.MakeDeploymentFolderAsync(_options.RemoteDeployBasePath);
+ await ssh.CleanFolderAsync(_launchBuilder.RemoteDeployProjectFolder);
+
+ await RunConfiguredCommandsAsync(ssh, _options.RemotePreDeployCommands);
+
+ await ssh.UploadFilesAsync(_launchBuilder.PublishDirFullPath, _launchBuilder.RemoteDeployProjectFolder);
+
+ // Transfer (tar/scp) from Windows does not preserve the exec bit, and `dotnet
+ // publish` always produces a native apphost executable (even framework-dependent).
+ await ssh.BashAsync($"chmod +x \"{_launchBuilder.RemoteDeployExecutableFilePath}\"");
+
+ await RunConfiguredCommandsAsync(ssh, _options.RemotePostDeployCommands);
}
////else if (buildOptions.HasFlag(BuildOptions.Publish))
////{
//// // This is PUBLISH not our 'deployer'
////}
+ cancellationToken.ThrowIfCancellationRequested();
+
// The following replaces -->> if (_options.RemoteDebugDisplayGui)
- if (buildOptions.HasFlag(BuildOptions.Launch))
+ if (buildOptions.HasFlag(BuildOptions.Launch) && !_options.AttachToRunningProcess)
{
- var cmd = $"DISPLAY=:0 dotnet \"{_launchBuilder.RemoteDeployAssemblyFilePath}\" &";
- //// var retPid = ssh.Bash(cmd);
-
+ var cmd = $"DISPLAY=:0 \"{_launchBuilder.RemoteDeployExecutableFilePath}\" &";
// RET: "[1] 31974"
var retPid = ssh.BashStream(cmd, "[");
Logger.Output($"Launch command returned: {retPid}");
-
- //ssh.BashStream("export DISPLAY=:0");
- //ssh.BashStream($"dotnet \"{_launchBuilder.RemoteDeployAssemblyFilePath}\"");
}
- if (buildOptions.HasFlag(BuildOptions.Debug) && buildOptions.HasFlag(BuildOptions.Launch))
- {
- ; // TODO: Find ProcId and set Launch.json to `Attach`
- }
- else if (buildOptions.HasFlag(BuildOptions.Debug))
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (buildOptions.HasFlag(BuildOptions.Debug))
{
- BuildDebugAttacher();
+ if (_options.AttachToRunningProcess)
+ {
+ // The debuggee is started/supervised externally (i.e. by systemd via the
+ // configured pre/post-deploy commands); attach to that exact process instead
+ // of launching a second, unmanaged instance of it -- a launched instance would
+ // not inherit the supervisor's EnvironmentFile and ambient capabilities, and
+ // would leave two copies of the program running.
+ var mainPid = (await ssh.BashAsync(_options.RemotePidCommand)).Trim();
+
+ if (int.TryParse(mainPid, out var pid) && pid > 0)
+ {
+ BuildDebugAttacher(pid);
+ }
+ else
+ {
+ Logger.Output($"Could not determine the running PID via '{_options.RemotePidCommand}' (got '{mainPid}'). Is the process actually running?");
+ }
+ }
+ else
+ {
+ BuildDebugAttacher();
+ }
}
}
BuildCleanup();
}
+ catch (OperationCanceledException)
+ {
+ Logger.Output("Stopped.");
+ BuildCleanup();
+ return false;
+ }
catch (Exception ex)
{
Logger.Output($"An error occurred during the build process. {ex.Message}");
@@ -167,6 +238,39 @@ private void BuildBegin()
_dte.Solution.SolutionBuild.BuildProject(_launchBuilder.ProjectConfigName, _launchBuilder.ProjectFileFullPath);
}
+ private void CancelBuild()
+ {
+ ThreadHelper.JoinableTaskFactory.Run(async () =>
+ {
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ try
+ {
+ if (_dte.Solution.SolutionBuild.BuildState == vsBuildState.vsBuildStateInProgress)
+ _dte.ExecuteCommand("Build.Cancel");
+ }
+ catch { }
+
+ // BuildEvents_OnBuildDone still fires after a cancel; unblock the awaiter either way.
+ _buildTask?.TrySetResult(false);
+ });
+ }
+
+ /// Runs each non-blank line of as a separate shell
+ /// command on the remote machine, in order.
+ private async Task RunConfiguredCommandsAsync(SshTool ssh, string commands)
+ {
+ if (string.IsNullOrWhiteSpace(commands))
+ return;
+
+ foreach (var line in commands.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var command = line.Trim();
+ if (command.Length > 0)
+ await ssh.BashAsync(command);
+ }
+ }
+
private void BuildCleanup()
{
// Not really needed
@@ -177,14 +281,81 @@ private void BuildCleanup()
//// BuildEvents.OnBuildProjConfigDone -= BuildEvents_OnBuildProjConfigDone;
}
+ /// Runs `dotnet publish` for into
+ /// . Always publishing (rather than uploading a
+ /// plain build's output folder) is what makes the deployed executable match what a manual
+ /// `dotnet publish`/VS Publish produces: even framework-dependent, `dotnet publish -r `
+ /// generates a native apphost executable (no bare `.dll` launch needed), whereas a plain build
+ /// does not reliably produce one. It also sidesteps the SDK nesting RID-specific output under
+ /// an extra subfolder that this extension would otherwise have to keep guessing at.
+ /// True if `dotnet publish` exited successfully.
+ private async Task PublishAsync(CancellationToken cancellationToken)
+ {
+ if (Directory.Exists(_launchBuilder.PublishDirFullPath))
+ Directory.Delete(_launchBuilder.PublishDirFullPath, recursive: true);
+
+ var selfContained = _options.UseSelfContainedDeployment ? "true" : "false";
+ var args = $"publish \"{_launchBuilder.ProjectFileFullPath}\" " +
+ $"-c \"{_launchBuilder.ProjectConfigName}\" " +
+ $"-r \"{_options.RemoteRuntimeIdentifier}\" " +
+ $"--self-contained {selfContained} " +
+ $"-o \"{_launchBuilder.PublishDirFullPath}\"";
+
+ Logger.Output($"PUBLISH> dotnet {args}");
+
+ var startInfo = new ProcessStartInfo("dotnet", args)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ WorkingDirectory = Path.GetDirectoryName(_launchBuilder.ProjectFileFullPath),
+ };
+
+ using (var process = new Process { StartInfo = startInfo })
+ using (cancellationToken.Register(() =>
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill();
+ }
+ catch { }
+ }))
+ {
+ process.Start();
+
+ var stdOutTask = process.StandardOutput.ReadToEndAsync();
+ var stdErrTask = process.StandardError.ReadToEndAsync();
+
+ await Task.Run(() => process.WaitForExit());
+
+ var stdOut = await stdOutTask;
+ var stdErr = await stdErrTask;
+
+ if (!string.IsNullOrWhiteSpace(stdOut))
+ Logger.Output(stdOut);
+ if (!string.IsNullOrWhiteSpace(stdErr))
+ Logger.Output(stdErr);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return process.ExitCode == 0;
+ }
+ }
+
///
/// Start debugging using the remote visual studio server adapter
///
- private void BuildDebugAttacher()
+ /// When set, attach to this already-running remote process
+ /// instead of launching a new one.
+ private void BuildDebugAttacher(int? attachToPid = null)
{
////_launchJsonPath = _launchBuilder.GenerateLaunchJson();
- _launchJsonPath = _launchBuilder.GenerateLaunchJson(vsdbgLogging: true);
+ _launchJsonPath = attachToPid.HasValue
+ ? _launchBuilder.GenerateAttachLaunchJson(attachToPid.Value, vsdbgLogging: true)
+ : _launchBuilder.GenerateLaunchJson(vsdbgLogging: true);
if (string.IsNullOrEmpty(_launchJsonPath))
{
Logger.Output("Could not generate 'launch.json'. Potential folder creation permissions in project's output directory.");
@@ -237,10 +408,6 @@ private bool Initialize()
_launchBuilder = new LaunchBuilder(dte, project, _options);
- // TODO: Commandline Args
- //// if (_options.UseCommandLineArgs)
- //// _launchBuilder.CommandLineArgs = ... extract from localSettings.json
-
return true;
}
@@ -256,6 +423,7 @@ private SshConnectionInfo GetRemoteConnectionInfo()
PrivateKeyEnabled = _options.UserPrivateKeyEnabled,
PrivateKeyPath = _options.UserPrivateKeyPath,
PrivateKeyPassword = _options.UserPrivateKeyPassword,
+ CertificatePath = _options.UserCertificatePath,
};
}
diff --git a/src/VsLinuxDebugger/Core/SshConnectionInfo.cs b/src/VsLinuxDebugger/Core/SshConnectionInfo.cs
index 8fcb620..52a2944 100644
--- a/src/VsLinuxDebugger/Core/SshConnectionInfo.cs
+++ b/src/VsLinuxDebugger/Core/SshConnectionInfo.cs
@@ -18,5 +18,10 @@ public struct SshConnectionInfo
public string PrivateKeyPath { get; set; }
public string PrivateKeyPassword { get; set; }
+
+ /// Explicit path to an OpenSSH certificate file (i.e. a CA-signed
+ /// '<key>-cert.pub'). When blank, the certificate is looked up next to
+ /// using the OpenSSH naming convention.
+ public string CertificatePath { get; set; }
}
}
diff --git a/src/VsLinuxDebugger/Core/SshTool.cs b/src/VsLinuxDebugger/Core/SshTool.cs
index d8d20dd..01cc555 100644
--- a/src/VsLinuxDebugger/Core/SshTool.cs
+++ b/src/VsLinuxDebugger/Core/SshTool.cs
@@ -163,10 +163,19 @@ public async Task ConnectAsync()
{
Logger.Output($"SSH configuring private key connection...");
- if (string.IsNullOrEmpty(_info.PrivateKeyPassword))
- keyFile = new PrivateKeyFile(_info.PrivateKeyPath);
+ // Explicit path wins; otherwise, OpenSSH convention: "-cert.pub".
+ var certificatePath = !string.IsNullOrEmpty(_info.CertificatePath)
+ ? _info.CertificatePath
+ : $"{_info.PrivateKeyPath}-cert.pub";
+
+ if (!File.Exists(certificatePath))
+ certificatePath = null;
else
- keyFile = new PrivateKeyFile(_info.PrivateKeyPath, _info.PrivateKeyPassword);
+ Logger.Output($"SSH found matching certificate '{certificatePath}'.");
+
+ keyFile = string.IsNullOrEmpty(_info.PrivateKeyPassword)
+ ? new PrivateKeyFile(_info.PrivateKeyPath, null, certificatePath)
+ : new PrivateKeyFile(_info.PrivateKeyPath, _info.PrivateKeyPassword, certificatePath);
/**
// adds rsa-sha2-256
@@ -212,8 +221,8 @@ public async Task ConnectAsync()
catch (Exception)
{
_scp = (_info.PrivateKeyEnabled && File.Exists(_info.PrivateKeyPath))
- ? new ScpClient(connInfo)
- : new ScpClient(_info.Host, _info.Port, _info.UserName, _info.UserPass);
+ ? new ScpClient(connInfo, RemotePathTransformation.ShellQuote)
+ : new ScpClient(_info.Host, _info.Port, _info.UserName, _info.UserPass, RemotePathTransformation.ShellQuote);
_scp.Connect();
}
@@ -317,7 +326,7 @@ public async Task UploadFilesAsync(string sourceFolder, string targetFol
var success = await PayloadCompressAndUploadAsync(_sftp, srcDirInfo, destTarGz);
// Decompress file
- await PayloadDecompressAsync(targetFolder, destTarGz, false);
+ await PayloadDecompressAsync(targetFolder, destTarGz, true);
Logger.Output($"Upload completed {(success ? "successfully" : "with failure")}.");
@@ -433,7 +442,7 @@ await Task.Run(() =>
{
try
{
- using (var tarGzWriter = WriterFactory.Open(tarGzStream, ArchiveType.Tar, CompressionType.GZip))
+ using (var tarGzWriter = WriterFactory.OpenWriter(tarGzStream, ArchiveType.Tar, WriterOptions.ForTar(CompressionType.GZip)))
{
using (MemoryStream fileStream = new MemoryStream())
{
diff --git a/src/VsLinuxDebugger/Core/UserOptions.cs b/src/VsLinuxDebugger/Core/UserOptions.cs
index 81bb35d..e71b482 100644
--- a/src/VsLinuxDebugger/Core/UserOptions.cs
+++ b/src/VsLinuxDebugger/Core/UserOptions.cs
@@ -11,19 +11,47 @@ public class UserOptions
public string LocalPLinkPath { get; set; }
public bool LocalSwitchLinuxDbgOutput { get; set; }
+ /// When Stop cancels an in-progress build/deploy, also immediately close the SSH
+ /// connection even mid-command. Default false lets the current remote step finish first.
+ public bool ForceKillOnStop { get; set; } = false;
+
public bool RemoteDebugDisplayGui { get; set; }
public string RemoteDebugDisplayNumber { get; set; }
public string RemoteDeployBasePath { get; set; }
- /// Full path to `dotnet` executable.
- public string RemoteDotNetPath { get; set; }
+
+ /// Environment variables to pass to the debuggee, one `KEY=VALUE` pair per line.
+ public string RemoteEnvironmentVariables { get; set; }
+ /// Shell commands run on the remote machine before files are uploaded, one per
+ /// line, only when Deploy runs (i.e. stopping whatever supervises/holds the debuggee).
+ public string RemotePreDeployCommands { get; set; }
+
+ /// Shell commands run on the remote machine after files are uploaded, one per
+ /// line, only when Deploy runs (i.e. restarting a supervisor so it picks up the new build).
+ public string RemotePostDeployCommands { get; set; }
+
+ /// When enabled, the debuggee is presumed to be started/supervised externally, so
+ /// debugging attaches to its existing process (resolved via )
+ /// instead of vsdbg launching a new one.
+ public bool AttachToRunningProcess { get; set; } = false;
+
+ /// Shell command, run on the remote machine, whose output is the PID to attach to.
+ /// Only used when is enabled.
+ public string RemotePidCommand { get; set; }
/// Base path to VSDBG (i.e. `~/.vsdbg`).
public string RemoteVsDbgBasePath { get; set; }
/// Full path to VS Debugger.
public string RemoteVsDbgFullPath => LinuxPath.Combine(RemoteVsDbgBasePath, Constants.VS2022, Constants.AppVSDbg);
- public bool UseCommandLineArgs { get; set; }
public bool UsePublish { get; set; }
+ /// When enabled, launches the deployed program directly as a native executable
+ /// (i.e. a self-contained/AOT publish) instead of via `dotnet <assembly>.dll`.
+ public bool UseSelfContainedDeployment { get; set; } = false;
+
+ /// .NET Runtime Identifier to publish for when
+ /// is enabled (i.e. `linux-arm64`).
+ public string RemoteRuntimeIdentifier { get; set; } = "linux-arm64";
+
public string UserGroupName { get; set; }
public string UserName { get; set; }
public string UserPass { get; set; }
@@ -31,6 +59,18 @@ public class UserOptions
public string UserPrivateKeyPath { get; set; }
public string UserPrivateKeyPassword { get; set; }
+ /// Explicit path to an OpenSSH certificate file (i.e. a CA-signed
+ /// '<key>-cert.pub'). When blank, the certificate is looked up next to
+ /// using the OpenSSH naming convention.
+ public string UserCertificatePath { get; set; }
+
public bool UseSSHExeEnabled { get; set; } = false;
+
+ /// Command used to elevate the debugger process on the remote machine (i.e. `sudo -n -E`).
+ public string SudoCommand { get; set; } = Constants.DefaultSudoCommand;
+
+ /// When enabled, launches VSDBG via on the remote machine.
+ /// Use this when the debuggee runs with elevated/ambient capabilities the debugger must match to attach.
+ public bool UseSudoForDebugger { get; set; } = false;
}
}
diff --git a/src/VsLinuxDebugger/DebuggerPackage.cs b/src/VsLinuxDebugger/DebuggerPackage.cs
index a3aed13..e8e29a6 100644
--- a/src/VsLinuxDebugger/DebuggerPackage.cs
+++ b/src/VsLinuxDebugger/DebuggerPackage.cs
@@ -25,13 +25,25 @@ namespace VsLinuxDebugger
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[Guid(DebuggerPackage.PackageGuidString)]
[ProvideMenuResource("Menus.ctmenu", 1)]
- [ProvideOptionPage(typeof(OptionsPage), "Linux Debugger", "General", 0, 0, true)]
+ [ProvideOptionPage(typeof(RemoteHostOptionsPage), "Linux Debugger", "Remote Host", 0, 0, true, Sort = 1)]
+ [ProvideOptionPage(typeof(RemoteCredentialsOptionsPage), "Linux Debugger", "Remote Credentials", 0, 0, true, Sort = 2)]
+ [ProvideOptionPage(typeof(RemoteDebuggerOptionsPage), "Linux Debugger", "Remote Debugger", 0, 0, true, Sort = 3)]
+ [ProvideOptionPage(typeof(RemoteLaunchOptionsPage), "Linux Debugger", "Remote Launch", 0, 0, true, Sort = 4)]
+ [ProvideOptionPage(typeof(LocalOptionsPage), "Linux Debugger", "Local", 0, 0, true, Sort = 5)]
public sealed partial class DebuggerPackage : AsyncPackage
{
/// Package GUID string.
public const string PackageGuidString = "19f87f23-7a2c-4279-ac7c-c9267776bbf9";
- public OptionsPage VsixOptions => (OptionsPage)GetDialogPage(typeof(OptionsPage));
+ public RemoteHostOptionsPage RemoteHostOptions => (RemoteHostOptionsPage)GetDialogPage(typeof(RemoteHostOptionsPage));
+
+ public RemoteCredentialsOptionsPage RemoteCredentialsOptions => (RemoteCredentialsOptionsPage)GetDialogPage(typeof(RemoteCredentialsOptionsPage));
+
+ public RemoteDebuggerOptionsPage RemoteDebuggerOptions => (RemoteDebuggerOptionsPage)GetDialogPage(typeof(RemoteDebuggerOptionsPage));
+
+ public RemoteLaunchOptionsPage RemoteLaunchOptions => (RemoteLaunchOptionsPage)GetDialogPage(typeof(RemoteLaunchOptionsPage));
+
+ public LocalOptionsPage LocalOptions => (LocalOptionsPage)GetDialogPage(typeof(LocalOptionsPage));
///
/// Initialization of the package; this method is called right after the package is sited, so this is the place
@@ -47,7 +59,7 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke
await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
await Commands.InitializeAsync(this);
- Logger.Init(this, OutputWindowType.Custom, VsixOptions.SwitchLinuxDbgOutput);
+ Logger.Init(this, OutputWindowType.Custom, LocalOptions.SwitchLinuxDbgOutput);
Logger.Output("InitializeAsync");
}
}
diff --git a/src/VsLinuxDebugger/DebuggerPackage.vsct b/src/VsLinuxDebugger/DebuggerPackage.vsct
index be2a4f9..52467db 100644
--- a/src/VsLinuxDebugger/DebuggerPackage.vsct
+++ b/src/VsLinuxDebugger/DebuggerPackage.vsct
@@ -87,40 +87,36 @@
DynamicVisibility
TextChanges
-
- Build, Deploy and Debug (experimental)
+ Build, Deploy and Debug
-