Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/project-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
branches:
- 'feature/**'
- 'bugfix/**'
- 'copilot/**'

permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The extension supports
- SSH authentication using private keys.
- Debugging .NET framework dependant and self contained application.
- Deploying the project output folder via SCP or Rsync to the target device.
- Deploying additional files/folders (configured in the launch profile) to the target device.
- Publish (running dotnet publish) before deploying the application.
- Installing vsdbg automatically (configurable).
- Installing .NET on the target device (.NET 6 and newer)
Expand Down
10 changes: 10 additions & 0 deletions docs/LaunchProfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The following launch profile properties are independant of the application type:
"dotNetInstallFolderPath": "~\\.dotnet",
"debuggerInstallFolderPath": "~\\.vsdbg",
"appFolderPath": "~\\AppFolder",
"additionalFiles": "Data\\Files\\data.txt|hello.txt;Data\\Files\\data.txt|/home/mko/hello.txt",
"additionalDirectories": "Data\\Directory|data;Data\\Directory|/home/mko/data"
"publishMode": "SelfContained"
"publishOnDeploy": false,
"deployOnStartDebugging": false,
Expand All @@ -37,6 +39,8 @@ The following launch profile properties are independant of the application type:
| dotNetInstallFolderPath | no | string | Linux path | The .NET install path |
| debuggerInstallFolderPath | no | string | Linux path | The vsdbg install path |
| appFolderPath | no | string | Linux path | The path where the app gets deployed to |
| additionalFiles | no | string | Multiple entries separated by ';'. Each entry: 'SourcePath|TargetPath' | Additional files to deploy |
| additionalDirectories | no | string | Multiple entries separated by ';'. Each entry: 'SourcePath|TargetPath' | Additional directories to deploy |
| publishMode | no | enum | SelfContained/FrameworkDependant | Publish mode |
| publishOnDeploy | no | boolean | true/false | Publish the app before deploy |
| deployOnStartDebugging | no | boolean | true/false | Deploy the app |
Expand Down Expand Up @@ -66,3 +70,9 @@ The following properties are specific to web projects:
|:----------- |:-------- |:---- |:----- | :------ |
| launchBrowser | no | boolean | true to launch the Webbrowser | Will launch the default browser as configured in Visual Studio |
| launchUrl | yes | string | Valid URL | The URL the browser should navigate to |

## Additional Files and Directories
The additional files and directories to deploy can be specified in the launch profile using the properties `additionalFiles` and `additionalDirectories`.
* All relative source paths for Windows are relative to the project folder.
* All relative target paths (do not begin with /) for Linux are relative to the configured `appFolderPath`.
* The entries in the additionalFiles are assumed to be files, the entries in additionalDirectories are assumed to be directories.
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
// </copyright>
// ----------------------------------------------------------------------------

using System;

namespace RemoteDebuggerLauncher.Infrastructure
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ namespace RemoteDebuggerLauncher.Infrastructure
/// <summary>
/// Utility class for parsing additional deployment configuration strings.
/// </summary>
/// <remarks>
/// The parser assumes that:
/// * the configuration string is in the format 'source1|target1;source2|target2',
/// * target path ending with a / indicates a folder deployment
/// </remarks>
internal class AdditionalDeploymentParser
{
private readonly string localProjectPath;
Expand All @@ -35,9 +40,10 @@ public AdditionalDeploymentParser(string localProjectPath, string remoteAppFolde
/// Parses a configuration string containing multiple file or folder mappings.
/// </summary>
/// <param name="configurationString">The configuration string in format 'source1|target1;source2|target2'.</param>
/// <param name="file">Indicates whether the mapping is for a file or a folder.</param>
/// <returns>A list of <see cref="AdditionalDeploymentEntry"/> objects.</returns>
/// <exception cref="ArgumentException">Thrown when the configuration string format is invalid.</exception>
public IList<AdditionalDeploymentEntry> Parse(string configurationString)
public IList<AdditionalDeploymentEntry> Parse(string configurationString, bool file)
{
var result = new List<AdditionalDeploymentEntry>();

Expand All @@ -56,45 +62,81 @@ public IList<AdditionalDeploymentEntry> Parse(string configurationString)
}

// Split by pipe to get source and target
var parts = trimmedEntry.Split(new char[] { '|' }, StringSplitOptions.None);
if (parts.Length != 2)
{
throw new ArgumentException($"Invalid additional deployment entry format: '{trimmedEntry}'. Expected format: 'source|target'.", nameof(configurationString));
}
var (sourcePath, targetPath) = ParseEntry(trimmedEntry);

var sourcePath = parts[0].Trim();
var targetPath = parts[1].Trim();
// If target path is relative, make it relative to the project directory
sourcePath = MakeAbsoluteLocalPath(sourcePath);

if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException($"Source path cannot be empty in entry: '{trimmedEntry}'.", nameof(configurationString));
}
// If target path is relative, make it relative to the remote application folder
targetPath = MakeAbsoluteRemotePath(targetPath);

if (string.IsNullOrEmpty(targetPath))
if (file)
{
throw new ArgumentException($"Target path cannot be empty in entry: '{trimmedEntry}'.", nameof(configurationString));
// in case the target path denotes a folder, append the file name
if (UnixPath.DenotesFolder(targetPath))
{
var fileName = Path.GetFileName(sourcePath);
targetPath = UnixPath.Combine(targetPath, fileName);
}
}

// If target path is relative, make it relative to the project directory
if (!Path.IsPathRooted(sourcePath))
else
{
sourcePath = Path.GetFullPath(Path.Combine(localProjectPath, sourcePath));
// For directories, ensure trailing slash
targetPath = UnixPath.AppendTrailingSlash(targetPath);
}

// If target path is relative, make it relative to the remote application folder
if (!UnixPath.IsPathRooted(targetPath))
{
targetPath = UnixPath.Combine(remoteAppFolderPath, targetPath);
}

// append a slash to make sure the target is treated as a directory
targetPath = UnixPath.AppendTrailingSlash(targetPath);

result.Add(new AdditionalDeploymentEntry(sourcePath, targetPath));
}
}

return result;
}

private static (string sourcePath, string targetPath) ParseEntry(string entry)
{
var parts = entry.Split(new char[] { '|' }, StringSplitOptions.None);
if (parts.Length != 2)
{
throw new ArgumentException($"Invalid additional deployment entry format: '{entry}'. Expected format: 'source|target'.", nameof(entry));
}

var sourcePath = parts[0].Trim();
var targetPath = parts[1].Trim();

if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException($"Source path cannot be empty in entry: '{entry}'.", nameof(entry));
}

if (string.IsNullOrEmpty(targetPath))
{
throw new ArgumentException($"Target path cannot be empty in entry: '{entry}'.", nameof(entry));
}
return (sourcePath, targetPath);
}

private string MakeAbsoluteLocalPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
else
{
return Path.GetFullPath(Path.Combine(localProjectPath, path));
}
}

private string MakeAbsoluteRemotePath(string path)
{
if (UnixPath.IsPathRooted(path))
{
return path;
}
else
{
return UnixPath.Combine(remoteAppFolderPath, path);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// <copyright company="Michael Koster">
// Copyright (c) Michael Koster. All rights reserved.
// Licensed under the MIT License.
Expand Down
13 changes: 12 additions & 1 deletion src/Extension/RemoteDebuggerLauncher/Infrastructure/UnixPath.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// <copyright company="Michael Koster">
// Copyright (c) Michael Koster. All rights reserved.
// Licensed under the MIT License.
Expand Down Expand Up @@ -87,6 +87,17 @@ public static string AppendTrailingSlash(string path)
return path;
}

/// <summary>
/// Returns a value indicating whether the specified path denotes folder.
/// </summary>
/// <param name="path">The path.</param>
/// <returns><c>true</c> if the path denotes a folder; otherwise, <c>false</c>.</returns>
public static bool DenotesFolder(string path)
{
ThrowIf.ArgumentNullOrEmpty(path, nameof(path));
return path.EndsWith("/");
}

/// <summary>
/// Gets the name of the directory.
/// </summary>
Expand Down
53 changes: 50 additions & 3 deletions src/Extension/RemoteDebuggerLauncher/PackageConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ namespace RemoteDebuggerLauncher
/// </summary>
internal static class PackageConstants
{
/// <summary>
/// Local filesystem related constants.
/// </summary>
public static class FileSystem
{
/// <summary>Directory under %localappdata% where to store data like Logs and cached assets.</summary>
public const string StorageFolder = @"RemoteDebuggerLauncher";
}

/// <summary>
/// Common Project System related constants
/// </summary>
Expand Down Expand Up @@ -86,7 +95,7 @@ public static class Options
public const string DefaultValueToolsInstallFolderPath = "~/.rdl";

/// <summary>The default value for the App folder path on the device page.</summary>
public const string DefaultValueAppFolderPath = "~/project";
public const string DefaultValueAppFolderPath = "~/$(MSBuildProjectName)";
}

/// <summary>
Expand All @@ -104,7 +113,7 @@ public static class Dotnet
public const string GetInstallDotnetPs1Url = "https://dot.net/v1/dotnet-install.ps1";

/// <summary>Directory under %localappdata% where to cache the .NET downloads.</summary>
public const string DownloadCacheFolder = @"RemoteDebuggerLauncher\dotnet";
public const string DownloadCacheFolder = FileSystem.StorageFolder + @"\dotnet";
}

/// <summary>
Expand Down Expand Up @@ -142,7 +151,7 @@ public static class Debugger
public const string GetVsDbgPs1Url = "https://aka.ms/getvsdbgps1";

/// <summary>Directory under %localappdata% where to cache the remote debugger downloads.</summary>
public const string DownloadCacheFolder = @"RemoteDebuggerLauncher\vsdbg\vs2022";
public const string DownloadCacheFolder = FileSystem.StorageFolder + @"\vsdbg\vs2022";
}

public static class Commands
Expand Down Expand Up @@ -197,5 +206,43 @@ public static class SecureShell
/// <summary>HTTPS Developer Certificate name.</summary>
public const string HttpsCertificateName = "DevCert.pfx";
}

/// <summary>
/// Holder for all Linux Shell commands executed via SSH.
/// </summary>
public static class LinuxShellCommands
{
private const string MkDir = "mkdir -p \"{0}\"";
private const string Rm = "rm {0}";
private const string RmF = "rm -f {0}";
private const string RmRf = "rm -rf {0}";
private const string Chmod = "chmod {0} {1}";
private const string ChmodPlusX = "chmod +x {0}";
private const string Command = "command -v {0}";

/// <summary>"pwd" - Command to get the current working directory.</summary>
public const string Pwd = "pwd";

/// <summary>"mkdir -p \"{0}\"" - Formats the CreateDirectory command with the specified path.</summary>
public static string FormatMkDir(string path) => string.Format(MkDir, path);

/// <summary>"rm {0}" - Formats the RemoveFile command with the specified path.</summary>
public static string FormatRm(string path) => string.Format(Rm, path);

/// <summary>"rm -f {0}" - Formats the ForceRemoveFile command with the specified path.</summary>
public static string FormatRmF(string path) => string.Format(RmF, path);

/// <summary>"rm -rf {0}" - Formats the RemoveDirectory command with the specified path.</summary>
public static string FormatRmRf(string path) => string.Format(RmRf, path);

/// <summary>"chmod {0} {1}" - Formats the chmod command with the specified mode and path.</summary>
public static string FormatChmod(string mode, string path) => string.Format(Chmod, mode, path);

/// <summary>"chmod +x {0}" - Formats the chmod command with the specified path.</summary>
public static string FormatChmodPlusX(string path) => string.Format(ChmodPlusX, path);

/// <summary>"command -v {0}" - Formats the command to check if a command exists on the remote system.</summary>
public static string FormatCommand(string commandName) => string.Format(Command, commandName);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// <copyright company="Michael Koster">
// Copyright (c) Michael Koster. All rights reserved.
// Licensed under the MIT License.
Expand All @@ -10,6 +10,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.VisualStudio.ProjectSystem;
using Microsoft.VisualStudio.ProjectSystem.Debug;
using RemoteDebuggerLauncher.Infrastructure;
using RemoteDebuggerLauncher.RemoteOperations;

Expand All @@ -25,13 +26,15 @@ internal class SecureShellDeployService : ISecureShellDeployService
private readonly ConfiguredProject configuredProject;
private readonly IDotnetPublishService publishService;
private readonly ISecureShellRemoteOperationsService remoteOperations;
private readonly IDebugTokenReplacer tokenReplacer;

public SecureShellDeployService(ConfigurationAggregator configurationAggregator, ConfiguredProject configuredProject, IDotnetPublishService publishService, ISecureShellRemoteOperationsService remoteOperations)
public SecureShellDeployService(ConfigurationAggregator configurationAggregator, ConfiguredProject configuredProject, IDotnetPublishService publishService, ISecureShellRemoteOperationsService remoteOperations, IDebugTokenReplacer tokenReplacer)
{
this.configurationAggregator = configurationAggregator;
this.configuredProject = configuredProject;
this.publishService = publishService;
this.remoteOperations = remoteOperations;
this.tokenReplacer = tokenReplacer;
}

/// <inheritdoc />
Expand Down Expand Up @@ -72,7 +75,8 @@ private async Task DeployApplicationBinariesAsync(bool clean)
if (configurationAggregator.QueryPublishOnDeploy() && configurationAggregator.QueryPublishMode() == Shared.PublishMode.SelfContained)
{
var binaryName = await configuredProject.GetAssemblyNameAsync();
var remotePath = UnixPath.Combine(configurationAggregator.QueryAppFolderPath(), binaryName);
var remotePath = await tokenReplacer.ReplaceTokensInStringAsync(configurationAggregator.QueryAppFolderPath(), false);
remotePath = UnixPath.Combine(remotePath, binaryName);

// change file permission to rwx,r,r
await remoteOperations.ChangeRemoteFilePermissionAsync(remotePath, "rwxr--r--");
Expand All @@ -90,8 +94,9 @@ private async Task DeployAdditionalFilesAsync()
var additionalFilesConfig = configurationAggregator.QueryAdditionalFiles();
if (!string.IsNullOrEmpty(additionalFilesConfig))
{
var parser = new AdditionalDeploymentParser(configuredProject.GetProjectFolder(), configurationAggregator.QueryAppFolderPath());
var additionalFiles = parser.Parse(additionalFilesConfig);
var appFolderPath = await tokenReplacer.ReplaceTokensInStringAsync(configurationAggregator.QueryAppFolderPath(), false);
var parser = new AdditionalDeploymentParser(configuredProject.GetProjectFolder(), appFolderPath);
var additionalFiles = parser.Parse(additionalFilesConfig, true);

foreach (var fileEntry in additionalFiles)
{
Expand Down Expand Up @@ -123,8 +128,9 @@ private async Task DeployAdditionalDirectoriesAsync(bool clean)
var additionalDirectoriesConfig = configurationAggregator.QueryAdditionalDirectories();
if (!string.IsNullOrEmpty(additionalDirectoriesConfig))
{
var parser = new AdditionalDeploymentParser(configuredProject.GetProjectFolder(), configurationAggregator.QueryAppFolderPath());
var additionalDirectories = parser.Parse(additionalDirectoriesConfig);
var appFolderPath = await tokenReplacer.ReplaceTokensInStringAsync(configurationAggregator.QueryAppFolderPath(), false);
var parser = new AdditionalDeploymentParser(configuredProject.GetProjectFolder(), appFolderPath);
var additionalDirectories = parser.Parse(additionalDirectoriesConfig, false);

// Validate that all source directories exist
var missingDirectory = additionalDirectories.FirstOrDefault(directoryEntry => !Directory.Exists(directoryEntry.SourcePath));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// <copyright company="Michael Koster">
// Copyright (c) Michael Koster. All rights reserved.
// Licensed under the MIT License.
Expand Down Expand Up @@ -26,7 +26,7 @@ internal sealed class ConfiguredPackageServiceFactory : PackageServiceFactory, I

[ImportingConstructor]
public ConfiguredPackageServiceFactory(SVsServiceProvider asyncServiceProvider, IVsFacadeFactory facadeFactory, IDebugTokenReplacer tokenReplacer, ConfiguredProject configuredProject) :
base (asyncServiceProvider, facadeFactory, configuredProject)
base (asyncServiceProvider, facadeFactory, configuredProject, tokenReplacer)
{
this.tokenReplacer = tokenReplacer;
}
Expand Down
Loading
Loading