dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
14.91k stars 4.63k forks source link

Add Environment ApplicationDirectory #41341

Open eerhardt opened 4 years ago

eerhardt commented 4 years ago

Background and Motivation

The path of the current application is often needed to load files in your application directory. For example, appSettings.json configuration files, or image files.

This API is needed more than before now that we support single-file publishing and assemblies do not have physical file paths anymore. See https://github.com/dotnet/designs/blob/master/accepted/2020/form-factors.md#single-file for details.

We have AppContext.BaseDirectory, but that API is documented as:

Gets the pathname of the base directory that the assembly resolver uses to probe for assemblies.

There are scenarios where AppContext.BaseDirectory doesn't return the directory where the application lives, see https://github.com/dotnet/runtime/issues/40828#issuecomment-676519769 for example - PublishSingleFile in 3.0. Since AppContext.BaseDirectory is defined this way, it makes it unusable for the above purposes.

Proposed API

namespace System
{
    public partial class Environment
    {
        // Returns the directory path of the application.
        public string? ApplicationDirectory { get; }
    }
}

Usage Examples

string configurationFile = Path.Combine(Environment.ApplicationDirectory, "appSettings.json");
umbarov commented 4 years ago

Maybe a short version: AppDirectory? Like appSettings.json, System.AppContext System.AppDomain. For app consistency.

public string? AppDirectory { get; }

...
string configFile = Path.Combine(Environment.AppDirectory, "appSettings.json");
mklement0 commented 3 years ago

I suspect that is the plan anyway, but just to spell it out (correct me, if I'm wrong):

The return value will be the full, physical path - with symlinks, if any, resolved - of the directory in which the executable is located, so that, say, an executable /path/to/exe that is symlinked to /usr/bin/exe and invoked as such still reports /path/to.

[Applies only to regular, application-specific executables - see next comment] (Expressed in terms of the upcoming Environment.ProcessPath property, which returns the executable's full, physical file path: Path.GetDirectoryName(Environment.ProcessPath)).

eerhardt commented 3 years ago

The return value will be the full, physical path - with symlinks, if any, resolved - of the directory in which the executable is located, so that, say, an executable /path/to/exe that is symlinked to /usr/bin/exe and invoked as such still reports /path/to.

I would assume all symlinks would be followed, yes.

(Expressed in terms of the upcoming Environment.ProcessPath property, which returns the executable's full, physical file path: Path.GetDirectoryName(Environment.ProcessPath).

No, this wouldn't be the case for all scenarios. One easy example is when you use dotnet.exe to load an application. dotnet.exe bin\Debug\net5.0\MyApp.dll. Your proposal would have Environment.ApplicationDirectory return the location of the dotnet.exe executable. But the intention is for this API to return the full path of where MyApp.dll is located.

Other scenarios where your above definition is incorrect could potentially be Mobile/Xamarin scenarios.

My thought is that the Environment.ApplicationDirectory is the full, physical file path to where the logical .NET "EntryPoint" assembly is located on disk. Even if the actual "EntryPoint" assembly is packaged in some sort of aggregate package - like it is in "single file" applications. If the "EntryPoint" assembly wasn't loaded from some physical file (for example Blazor WASM), then Environment.ApplicationDirectory returns null.

tannergooding commented 2 years ago

@eerhardt, so this basically gets the path to the folder containing the file that has the managed Main method (whether that remains implemented in IL or compiled down to native directly for AOT scenarios)?

tannergooding commented 2 years ago

Do you know how we would implement this differently from Assembly.GetEntryAssembly().Location?

jkotas commented 2 years ago

There are scenarios where AppContext.BaseDirectory doesn't return the directory where the application lives, see https://github.com/dotnet/runtime/issues/40828#issuecomment-676519769 for example

We have deprecated the "expand to temp single-file" publishing scheme that had this problem. The default single-file experience does not have this problem anymore. AppContext.BaseDirectory returns the directory where the application lives.

Do you know how we would implement this differently from Assembly.GetEntryAssembly().Location?

This implementation would not work for single-file where GetEntryAssembly().Location is empty string.

eerhardt commented 2 years ago

Do you know how we would implement this differently from Assembly.GetEntryAssembly().Location?

My thinking was that it would be an AppContext property that was passed from the host. Similar to how RuntimeInformation.RuntimeIdentifier is implemented:

https://github.com/dotnet/runtime/blob/cc9d46546b8e72b34a5f387a5ec9615197d79f1f/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/RuntimeInformation.cs#L46-L47

We definitely wouldn't want to use Assembly.Location, because we want this API to work in single-file and NativeAOT.

so this basically gets the path to the folder containing the file that has the managed Main method (whether that remains implemented in IL or compiled down to native directly for AOT scenarios)?

I think yes (more or less). For "mobile" scenarios or some other app scenarios, it might not be exactly that definition.

The default single-file experience does not have this problem anymore. AppContext.BaseDirectory returns the directory where the application lives.

Can we update documentation then? That way in the future we can guarantee that's what AppContext.BaseDirectory does?

jkotas commented 2 years ago

My thinking was that it would be an AppContext property that was passed from the host

How would the hosts that we maintain compute the value? What are the cases where this would be different from AppContext.BaseDirectory?

Can we update documentation then?

The documentation has a text to clarify this "In .NET 5 and later versions, for bundled assemblies, the value returned is the containing directory of the host executable." What else would you like to see the documentation to say?

eerhardt commented 1 year ago

What else would you like to see the documentation to say?

I would like to see the Summary change:

- Gets the file path of the base directory that the assembly resolver uses to probe for assemblies.
+ Gets the file path of the base directory of the application.

That way in the future when we have some new app model (shadow-copy? another extract assemblies thing?) that probes for assemblies in a different directory than where the application lives, we ensure that AppContext.BaseDirectory points to where consumers expect it to point to. The reason given why it wasn't fixed for the 3.x extract-to-temp single file app model was because the documentation says the API is for the assembly resolver. This broke a LOT of things that expected to use this API for reading files in the application directory. I'm trying to prevent this from happening again in the future.

jkotas commented 1 year ago

Sounds good to me.

dqwork commented 1 month ago

Just wanted to add to this conversation and provide an easy to repro example.

I just wanted to share the results of some testing I did and hopefully add some to this issue... or get told what I'm doing wrong thats causing this behavior :).

This is going to be pretty long, because my company won't let me post a gist...

Created a new dotnet 8 console app in Visual Studio 2022 (called WhereAmI)

WhereAmI.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RuntimeIdentifiers>win-x64;linux-x64</RuntimeIdentifiers>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>false</SelfContained>
    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
    <IncludeNativeLibrariesInSingleFile>true</IncludeNativeLibrariesInSingleFile>
    <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
  </PropertyGroup>
</Project>

Program.cs

using System.Diagnostics;
using System.Reflection;
using System;

namespace WhereAmI
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine($"Running on {Environment.OSVersion.Platform}");
            Console.WriteLine($"Dotnt Details:");
            Console.WriteLine(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);

            var appContextBaseDir = AppContext.BaseDirectory;
            var envProcessPath = Environment.ProcessPath;

            var execAssembly = Assembly.GetExecutingAssembly()?.Location;
            var entryAssembly = Assembly.GetEntryAssembly()?.Location;

            var processPath = Process.GetCurrentProcess().MainModule.FileName;

            Console.WriteLine($"AppContext.BaseDirectory: {appContextBaseDir}");
            Console.WriteLine($"Environment.ProcessPath: {envProcessPath}");
            Console.WriteLine($"Executing Assembly Path: {execAssembly}");
            Console.WriteLine($"Entry Assembly Path: {entryAssembly}");
            Console.WriteLine($"Current Process Filename: {processPath}");
        }
    }
}

I built the project using dotnet build -c Release I then ran dotnet publish -r win-x64 and dotnet publish -r linux-x64

Below are the results from testing

  1. Running the single file exe for windows (from bin/Release/win-x64/publish)
  2. Running the WhereAmI.dll (from bin/Release/win-x64) using dotnet (ie dotnet WhereAmI.dll)
  3. Running the single file produced for linux (from bin/Release/linux-x64/publish) on a linux machine
  4. Running the WhereAmI.dll (from bin/Release/linux-x64) using dotnet (ie dotnet WhereAmI.dll) on a linux machine

1. Windows Single File Exe

.\WhereAmI.exe
Running on Win32NT
Dotnt Details:
.NET 8.0.3
AppContext.BaseDirectory: C:\Users\******\AppData\Local\Temp\.net\WhereAmI\vh36hwyJjwSNejBA4Srlnov7e8lMgjc=\
Environment.ProcessPath: C:\Users\******\source\repos\WhereAmI\WhereAmI\bin\Release\win-x64\publish\WhereAmI.exe
Executing Assembly Path: C:\Users\******\AppData\Local\Temp\.net\WhereAmI\vh36hwyJjwSNejBA4Srlnov7e8lMgjc=\WhereAmI.dll
Entry Assembly Path: C:\Users\******\AppData\Local\Temp\.net\WhereAmI\vh36hwyJjwSNejBA4Srlnov7e8lMgjc=\WhereAmI.dll
Current Process Filename: C:\Users\******\source\repos\WhereAmI\WhereAmI\bin\Release\win-x64\publish\WhereAmI.exe

2. Windows run with dotnet

dotnet WhereAmI.dll
Running on Win32NT
Dotnt Details:
.NET 8.0.3
AppContext.BaseDirectory: C:\Users\******\source\repos\WhereAmI\WhereAmI\bin\Release\win-x64\
Environment.ProcessPath: c:\program files\dotnet\dotnet.exe
Executing Assembly Path: C:\Users\******\source\repos\WhereAmI\WhereAmI\bin\Release\win-x64\WhereAmI.dll
Entry Assembly Path: C:\Users\******\source\repos\WhereAmI\WhereAmI\bin\Release\win-x64\WhereAmI.dll
Current Process Filename: c:\program files\dotnet\dotnet.exe

3. Linux Single File

./WhereAmI
Running on Unix
Dotnt Details:
.NET 8.0.4
AppContext.BaseDirectory: /home/******/.net/WhereAmI/SoSD6MD9IYup9vfwv0YpqXytL2Zw2Fc=/
Environment.ProcessPath: /home/******/dotnet-testing/WhereAmI
Executing Assembly Path: /home/******/.net/WhereAmI/SoSD6MD9IYup9vfwv0YpqXytL2Zw2Fc=/WhereAmI.dll
Entry Assembly Path: /home/******/.net/WhereAmI/SoSD6MD9IYup9vfwv0YpqXytL2Zw2Fc=/WhereAmI.dll
Current Process Filename: /home/******/dotnet-testing/WhereAmI

4. Linux Run With dotnet

dotnet WhereAmI.dll
Running on Unix
Dotnt Details:
.NET 8.0.4
AppContext.BaseDirectory: /home/******/dotnet-testing/non-contained/
Environment.ProcessPath: /home/******/.dotnet/dotnet
Executing Assembly Path: /home/******/dotnet-testing/non-contained/WhereAmI.dll
Entry Assembly Path: /home/******/dotnet-testing/non-contained/WhereAmI.dll
Current Process Filename: /home/******/.dotnet/dotnet

As you can see from the results here there is no consistent approach to getting the location of the executable. Running in different ways gives different results making it quite awkward to reliably get the location and load things like config files, images or any other external, run time loaded resource.

Hope this was helpful in someway