Skip to content

Commit

Permalink
Merge pull request #50 from AdrasteonDev/main
Browse files Browse the repository at this point in the history
Fix elevation prompt on Windows
  • Loading branch information
jolexxa authored Dec 1, 2023
2 parents 1877e2c + a6cc85f commit 9c35643
Show file tree
Hide file tree
Showing 14 changed files with 215 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ public async Task Executes() {
var addonsFileRepo = new Mock<IAddonsFileRepository>();
var addonGraph = new Mock<IAddonGraph>();
var addonsRepo = new Mock<IAddonsRepository>();
var fileClient = new Mock<IFileClient>();
var logic = new Mock<AddonsLogic>(
addonsFileRepo.Object, addonsRepo.Object, addonGraph.Object
);
addonsFileRepo.Setup(ctx => ctx.FileClient).Returns(fileClient.Object);
fileClient.Setup(ctx => ctx.OS).Returns(OSType.Linux);

var addonsContext = new Mock<IAddonsContext>();
addonsContext.Setup(ctx => ctx.AddonsFileRepo)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ private static Subject BuildSubject(
var client = new Mock<IFileClient>();
var log = new Mock<ILog>();
var computer = new Mock<IComputer>();
var processRunner = new Mock<IProcessRunner>();
computer
.Setup(pc => pc.CreateShell(It.IsAny<string>()))
.Returns((string path) => {
Expand All @@ -59,7 +60,7 @@ private static Subject BuildSubject(
);
});
var config = new AddonsConfiguration(projectPath, addonsDir, cacheDir);
var repo = new AddonsRepository(client.Object, computer.Object, config);
var repo = new AddonsRepository(client.Object, computer.Object, config, processRunner.Object);
return new Subject(
console: console,
client: client,
Expand Down
6 changes: 4 additions & 2 deletions GodotEnv/src/Main.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ public static async Task<int> Main(string[] args) {
var addonsRepo = new AddonsRepository(
fileClient: fileClient,
computer: computer,
config: addonsConfig
config: addonsConfig,
processRunner: processRunner
);
var addonGraph = new AddonGraph();
var addonsLogic = new AddonsLogic(
Expand All @@ -81,7 +82,8 @@ public static async Task<int> Main(string[] args) {
networkClient: networkClient,
zipClient: zipClient,
platform: platform,
systemEnvironmentVariableClient: systemEnvironmentVariableClient
systemEnvironmentVariableClient: systemEnvironmentVariableClient,
processRunner: processRunner
);

var godotContext = new GodotContext(
Expand Down
21 changes: 21 additions & 0 deletions GodotEnv/src/common/clients/FileClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ T ReadJsonFile<T>(
T defaultValue
) where T : notnull;

/// <summary>Check if a file contains a given string.</summary>
/// <param name="path">File to read.</param>
/// <param name="str">The string to check.</param>
/// <returns>A boolean indicating if the file contains the string.</returns>
bool FileContains(string path, string str);

/// <summary>Read text lines from a file.</summary>
/// <param name="path">File to read.</param>
/// <returns>List of lines.</returns>
Expand Down Expand Up @@ -562,6 +568,21 @@ T defaultValue
return defaultValue;
}

public bool FileContains(string path, string str) {
if (Files.File.Exists(path)) {
try {
string content = Files.File.ReadAllText(path);
return content.Contains(str);
}
catch (Exception e) {
throw new IOException(
$"Failed to read file `{path}`", innerException: e
);
}
}
return false;
}

public void WriteJsonFile<T>(string filePath, T data) where T : notnull {
var contents = JsonConvert.SerializeObject(data, Formatting.Indented);
Files.File.WriteAllText(filePath, contents);
Expand Down
93 changes: 86 additions & 7 deletions GodotEnv/src/common/utilities/ProcessRunner.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
namespace Chickensoft.GodotEnv.Common.Utilities;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reactive.Linq;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;
using System.Threading.Tasks;
using CliWrap;
Expand Down Expand Up @@ -57,7 +60,19 @@ Action<string> onStdError
);

/// <summary>
/// Attempts to run a shell command as an administrator on Windows.
/// Checks if godotenv is elevated on Windows.
/// </summary>
/// <returns>A boolean indicating if godotenv is elevated</returns>
bool IsElevatedOnWindows();

/// <summary>
/// Attempts to restart godotenv with an administrator role on Windows.
/// </summary>
/// <returns>Process result task.</returns>
Task<ProcessResult> ElevateOnWindows();

/// <summary>
/// Attempts to run a shell command that requires an administrator role on Windows.
/// </summary>
/// <param name="exe">Process to run (must be in the system shell's path).
/// </param>
Expand Down Expand Up @@ -87,6 +102,59 @@ public async Task<ProcessResult> Run(
);
}

public bool IsElevatedOnWindows() {
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
throw new InvalidOperationException(
"IsElevatedOnWindows is only supported on Windows."
);
}

return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
}

public async Task<ProcessResult> ElevateOnWindows() {
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
throw new InvalidOperationException(
"ElevateOnWindows is only supported on Windows."
);
}

var argsList = Environment.GetCommandLineArgs();

// Regardless of the call context, the executable returned by GetCommandLineArgs
// is always a dll
// It can be executed with the dotnet command
string exe = argsList?.FirstOrDefault() ?? string.Empty;
if (exe.EndsWith(".exe")) exe = $"\"{exe}\"";
if (exe.EndsWith(".dll")) exe = $"dotnet \"{exe}\"";

string args = string.Join(
" ",
argsList?.Skip(1)?.Select(
arg => (arg?.Contains(' ') ?? false) ? $"\"{arg}\"" : arg
)?.ToList() ?? new List<string?>()
);

// Rerun the godotenv command with elevation in a new window
Process process = new() {
StartInfo = new() {
FileName = "cmd",
Arguments = $"/s /c \"cd /d \"{Environment.CurrentDirectory}\" & {exe} {args} & pause\"",
UseShellExecute = true,
Verb = "runas",
}
};

process.Start();
await process.WaitForExitAsync();

return new ProcessResult(
ExitCode: process.ExitCode,
StandardOutput: "",
StandardError: ""
);
}

public async Task<ProcessResult> RunElevatedOnWindows(
string exe, string args
) {
Expand All @@ -96,12 +164,23 @@ public async Task<ProcessResult> RunElevatedOnWindows(
);
}

var process = UACHelper.UACHelper.StartElevated(new ProcessStartInfo() {
FileName = exe,
Arguments = args,
UseShellExecute = true,
CreateNoWindow = false
});
// If a debugger is attached, the godotenv command is not elevated globally
if (!Debugger.IsAttached && !new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator)) {
throw new InvalidOperationException(
"RunElevatedOnWindows is only supported with admin role."
);
}

// If a debugger is attached, the process is elevated as the godotenv command is not elevated globally
Process process = new() {
StartInfo = new() {
FileName = exe,
Arguments = args,
UseShellExecute = Debugger.IsAttached,
Verb = Debugger.IsAttached ? "runas" : string.Empty,
CreateNoWindow = !Debugger.IsAttached,
}
};

process.Start();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace Chickensoft.GodotEnv.Features.Addons.Commands;

using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Chickensoft.GodotEnv.Common.Models;
using CliFx;
Expand All @@ -26,8 +27,17 @@ public AddonsInstallCommand(IExecutionContext context) {
public async ValueTask ExecuteAsync(IConsole console) {
var log = ExecutionContext.CreateLog(console);
var addonsFileRepo = ExecutionContext.Addons.AddonsFileRepo;
var addonsRepo = ExecutionContext.Addons.AddonsRepo;
var logic = ExecutionContext.Addons.AddonsLogic;

// The install command should be run with admin role on Windows if the addons file contains addons with a symlink source
// To be able to debug, godotenv is not elevated globally if a debugger is attached
if (addonsFileRepo.AddonsFileContainsSymlinkAddons(ExecutionContext.WorkingDir) && addonsFileRepo.FileClient.OS == OSType.Windows &&
!addonsRepo.ProcessRunner.IsElevatedOnWindows() && !Debugger.IsAttached) {
await addonsRepo.ProcessRunner.ElevateOnWindows();
return;
}

var binding = logic.Bind();

binding
Expand Down
16 changes: 16 additions & 0 deletions GodotEnv/src/features/addons/domain/AddonsFileRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ public interface IAddonsFileRepository {
/// <returns>Loaded addons file (or an empty one).</returns>
AddonsFile LoadAddonsFile(string projectPath, out string filename);

/// <summary>
/// Check the addons file and return true if an addon have a symlink source.
/// </summary>
/// <param name="projectPath">Where to search for an addons file.</param>
/// <returns>A boolean indicating if an addon have a symlink source in the addons file.</returns>
bool AddonsFileContainsSymlinkAddons(string projectPath);

/// <summary>
/// Creates an addons configuration object that represents the configuration
/// for how addons should be managed.
Expand Down Expand Up @@ -50,6 +57,15 @@ public AddonsFile LoadAddonsFile(string projectPath, out string filename) =>
defaultValue: new AddonsFile()
);

public bool AddonsFileContainsSymlinkAddons(string projectPath) {
string? file = FileClient.FileThatExists(new string[] { "addons.json", "addons.jsonc" }, projectPath);
if(string.IsNullOrEmpty(file)) return false;
return FileClient.FileContains(
path: file,
str: "\"symlink\""
);
}

public AddonsConfiguration CreateAddonsConfiguration(
string projectPath, AddonsFile addonsFile
) => new(
Expand Down
5 changes: 4 additions & 1 deletion GodotEnv/src/features/addons/domain/AddonsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public interface IAddonsRepository {
IFileClient FileClient { get; }
AddonsConfiguration Config { get; }
IComputer Computer { get; }
IProcessRunner ProcessRunner { get; }

/// <summary>
/// <para>
Expand Down Expand Up @@ -82,11 +83,13 @@ public interface IAddonsRepository {
public class AddonsRepository(
IFileClient fileClient,
IComputer computer,
AddonsConfiguration config
AddonsConfiguration config,
IProcessRunner processRunner
) : IAddonsRepository {
public IFileClient FileClient { get; } = fileClient;
public IComputer Computer { get; } = computer;
public AddonsConfiguration Config { get; } = config;
public IProcessRunner ProcessRunner { get; } = processRunner;

public string ResolveUrl(IAsset asset, string path) {
var url = asset.Url;
Expand Down
14 changes: 13 additions & 1 deletion GodotEnv/src/features/godot/commands/GodotUseCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Chickensoft.GodotEnv.Features.Addons.Commands;

using System.Diagnostics;
using System.Threading.Tasks;
using Chickensoft.GodotEnv.Common.Models;
using Chickensoft.GodotEnv.Features.Godot.Commands;
Expand Down Expand Up @@ -38,10 +39,21 @@ public GodotUseCommand(IExecutionContext context) {
}

public async ValueTask ExecuteAsync(IConsole console) {
var godotRepo = ExecutionContext.Godot.GodotRepo;
var platform = ExecutionContext.Godot.Platform;

// The use command must be run with the admin role on Windows
// To be able to debug, godotenv is not elevated globally if a debugger is attached
if (platform.FileClient.OS == OSType.Windows && !godotRepo.ProcessRunner.IsElevatedOnWindows() &&
!Debugger.IsAttached)
{
await godotRepo.ProcessRunner.ElevateOnWindows();
return;
}

var log = ExecutionContext.CreateLog(console);
var output = console.Output;

var godotRepo = ExecutionContext.Godot.GodotRepo;
var version = SemanticVersion.Parse(RawVersion);
var isDotnetVersion = !NoDotnet;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Chickensoft.GodotEnv.Features.Godot.Commands;

using System.Diagnostics;
using System.Threading.Tasks;
using Chickensoft.GodotEnv.Common.Models;
using CliFx;
Expand All @@ -19,6 +20,18 @@ public GodotCacheClearCommand(IExecutionContext context) {
}

public async ValueTask ExecuteAsync(IConsole console) {
var godotRepo = ExecutionContext.Godot.GodotRepo;
var platform = ExecutionContext.Godot.Platform;

// The clear command must be run with the admin role on Windows
// To be able to debug, godotenv is not elevated globally if a debugger is attached
if (platform.FileClient.OS == OSType.Windows && !godotRepo.ProcessRunner.IsElevatedOnWindows() &&
!Debugger.IsAttached)
{
await godotRepo.ProcessRunner.ElevateOnWindows();
return;
}

var log = ExecutionContext.CreateLog(console);

log.Print("");
Expand Down
13 changes: 12 additions & 1 deletion GodotEnv/src/features/godot/commands/env/GodotEnvSetupCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Chickensoft.GodotEnv.Features.Godot.Commands;

using System.Diagnostics;
using System.Threading.Tasks;
using Chickensoft.GodotEnv.Common.Models;
using CliFx;
Expand All @@ -19,9 +20,19 @@ public GodotEnvSetupCommand(IExecutionContext context) {
}

public async ValueTask ExecuteAsync(IConsole console) {
var log = ExecutionContext.CreateLog(console);
var godotRepo = ExecutionContext.Godot.GodotRepo;
var platform = ExecutionContext.Godot.Platform;

// The setup command must be run with the admin role on Windows
// To be able to debug, godotenv is not elevated globally if a debugger is attached
if (platform.FileClient.OS == OSType.Windows && !godotRepo.ProcessRunner.IsElevatedOnWindows() &&
!Debugger.IsAttached)
{
await godotRepo.ProcessRunner.ElevateOnWindows();
return;
}

var log = ExecutionContext.CreateLog(console);
await godotRepo.AddOrUpdateGodotEnvVariable(log);
}
}
Loading

0 comments on commit 9c35643

Please sign in to comment.