How to build self-sustained cross-platform GUI text editor with .NET core 3

This is an experimental proof-of-concept project.

And it works quite well for me.

Anyway - the entire source code from this article can be found on GitHub.

So, as it appears - it is not that hard to create self-sustained cross-platform software with latest .NET Core.

Generally speaking - there are two kinds of cross-platform software:

  • The first kind - requires individual building or compilation for each platform that it supports.

  • The second kind - can be directly run on any platform without special preparation.

.NET Core has supported the second kind of cross-platform from the very start.

Meaning - it can run on other platforms without any platform without special preparation - as long as the targeted platform has .NET runtime libraries installed.

That is not exactly self-sustained as any application of such kind will depend on specific libraries.

With the version of .NET Core 3 - we finally have the option of building self-sustained executable file, that doesn't require any additional libraries or resources - single file publish for the specific platform of our choosing.

That means that any cross-platform software that is built in this way - will have to be of the first kind - it will require individual building or compilation for each platform that it supports.

So, my goals with this experimental proof-of-concept are the following:

  • Build the text editor GUI application that will receive a file name to edit from command line input.

  • Build single-file executables for multiple platforms (Windows, Linux, and macOS) from the same, unmodified source code - that will not depend on any pre-installed libraries or resources (can be run on the clean OS installation).

In order to achieve this - that single-file executable will have to contain some additional things, like:

  • entire web server that will serve our GUI, and

  • all of the web resources that application requires - like scripts and images embedded into executable resources.

Let's see how it turned out:

Text editor application

The text editor application is very simple and straightforward:

It will only use static web resources (one HTML file, one JavaScript file, two CSS files for styling and ICo file for application icon).

It will also receive the file name string to edit as a command-line argument. If the file doesn't exist it will be created.

So, we can modify our standard program entry point to do just that:

        public static void Main(string[] args)
        {
            if (args.Length == 0)
            {
                Console.WriteLine("Please specify file name you want to edit.");
                Console.WriteLine("Note: if file doesn't exists it will be created.");
                return;
            }

            var fileName = args[0];
            if (!File.Exists(fileName))
            {
                using (File.Create(fileName)) {}
            }
            FileController.Init(fileName);


            CreateHostBuilder(args).Build().Run();
        }

Also, there needs to be one REST endpoint that will handle getting and saving file content on GET and POST:

    [Route("api/file")]
    public class FileController : Controller
    {
        private static FileInfo Info { get; set; }
        private static readonly object LockObject = new object();

        public static void Init(string fileName)
        {
            Info = new FileInfo(fileName);
        }

        [HttpGet]
        public string Get()
        {
            this.Response.Headers.Add("x-file-name", Info.Name);
            this.Response.Headers.Add("x-full-name", Info.FullName);

            lock (LockObject)
            {
                return System.IO.File.ReadAllText(Info.Name);
            }
        }

        [HttpPost]
        public async Task Post()
        {
            using var reader = new StreamReader(Request.Body, Encoding.UTF8);
            var content = await reader.ReadToEndAsync();
            lock (LockObject)
            {
                System.IO.File.WriteAllText(Info.Name, content);
            }
        }
    }

HTML/JavaScript to handle all of this is also very simple and pretty much what would you expect:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <link rel="stylesheet" type="text/css" href="css/bootstrap.min.css" />
    <link rel="stylesheet" type="text/css" href="css/site.css" />
</head>
<body class="bg-secondary">

<div>
    <nav class="navbar navbar-expand-md navbar-dark bg-dark">
        <div class="container">
            <div>
                <div>
                    <p class="h5 text-white" id="title"></p>
                    <span class="badge badge-secondary">saved</span>
                </div>
                <span class="text-white small"></span>
            </div>

            <button class="btn btn-outline-success" id="save">Save</button>
        </div>
    </nav>

    <textarea class="container border border-primary form-control" id="editor"></textarea>

</div>

<script type="text/javascript" src="js/site.js"></script>

</body>
</html>
(async function() {

    const apiUrl = "api/file";
    const title = document.getElementById("title");
    const subTitle = document.querySelector("span.small");
    const save = document.getElementById("save");
    const editor = document.getElementById("editor");
    const badge = document.querySelector(".badge");
    badge.style["display"] = "none";

    const response = await fetch(apiUrl);
    const name = response.headers.get("x-file-name");
    const fullName = response.headers.get("x-full-name");

    title.innerHTML = document.title = name;
    subTitle.innerHTML = fullName;
    title.setAttribute("title", fullName);
    subTitle.setAttribute("title", fullName);

    editor.value = await response.text();
    editor.focus();

    save.addEventListener("click", async () => {

        await fetch(apiUrl, {
            method: "POST",
            body: editor.value,
            headers: {
                "Content-Type": "text/plain; charset=utf-8"
            }
        });
        editor.focus();
        badge.style["display"] = "";

    });

    editor.addEventListener("input", () => badge.style["display"] = "none");

})();

And with little help of bootstrap CSS and a little bit of custom CSS final product looks like this:

A pretty neat text editor with GUI. And it looks nice too, doesn't it...

Embedding static files to resources

Ok, so far so good.

Let's see how we can embed those static files into executable as resources and serve them from there.

The easiest way I could think of is to use .NET resource files. Those resource files are just simple XML files with RESX extension used primarily for handling localization support.

Inside that XML you can have any strings or any binary like images and sounds, but more importantly for this scenario - they can a reference to a file. We will use that.

To create a new resource file from Visual Studio you can use Add > New Item from Solution Explorer and select Resource File as described in the official documentation.

If you don't use Visual Studio then simply include Resource.resx XML file into your project. For valid schema, you can use the file from this project repository as a template. You can just ignore everything else except data elements inside root element.

So, to add a reference to an external file inside your resource file add data element inside root element like this:

  <data name="/index.html" type="System.Resources.ResXFileRef, System.Windows.Forms">
    <value>wwwroot/index.html;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
  </data>

This will define resource with key /index.html that points to a file wwwroot/index.html of System.String type with utf-8 encoding.

The basic idea here is to map request URL endpoints for a static file with a resource key, that will point to a physical file that will then be embedded into an executable file on each build.

Sounds good?

Ok, but there is a problem at the very start. The first request URL endpoint will be just / and if we give a resource key just / - I'll end up with runtime error. So I renamed it to a /index.html and I handle that special case later in code.

There is probably some better solution to this problem but for now, entire resources XML looks like this:

  <data name="/index.html" type="System.Resources.ResXFileRef, System.Windows.Forms">
    <value>wwwroot/index.html;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
  </data>
  <data name="/css/bootstrap.min.css" type="System.Resources.ResXFileRef, System.Windows.Forms">
    <value>wwwroot/css/bootstrap.min.css;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
  </data>
  <data name="/css/site.css" type="System.Resources.ResXFileRef, System.Windows.Forms">
    <value>wwwroot/css/site.css;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
  </data>
  <data name="/js/site.js" type="System.Resources.ResXFileRef, System.Windows.Forms">
    <value>wwwroot/js/site.js;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
  </data>
  <data name="/favicon.ico" type="System.Resources.ResXFileRef, System.Windows.Forms">
    <value>wwwroot/favicon.ico;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
  </data>

Note that the last entry is not System.String, but System.Byte[] instead since it will be served as a binary file.

Now that we have that set-up, each time we build those files will end up as embedded resources inside our executable.

Well, almost. We now have to do clean operation before each build because resources from the last build will be remembered.

More on that when we start the actual building. Now we have to instruct webserver to serve from embedded resources.

Serving from embedded resources

To be able to serve content from embedded resources we need to map incoming requests to custom code that will do the following:

  1. Use the request URL to read the correct resources (using URL as key).
  2. Write appropriate headers and resource content to response

Response headers should be as follows:

  • content-type - appropriate MIME type for each file. HTML file is text/html; charset=UTF-8, CSS is text/css; charset=UTF-8 and so on.

  • cache-control - for default page (request /) we will use no cache (no-cache, no-store, "must-revalidate) because we want to serve index.html every time so we have opportunity to do a cache-busting when we serve the client with a new version. For everything else, values are max-age=3600.

  • Connection=keep-alive

  • content-length - represents the size in bytes of served content. For binary content that is evaluated with content.Length and for string is evaluated with System.Text.Encoding.Default.GetByteCount(content).

  • etag - we can use the version number for etag.

And finally to write content we can use await context.Response.WriteAsync(Manager.GetString(resourceId)); for string content and await context.Response.BodyWriter.WriteAsync(Manager.GetObject(resourceId) as byte[]); for binary content.

The final version of my resource middleware looks like this:

    public static class ResourceMiddleware
    {
        private static readonly Assembly ExecutingAssembly = Assembly.GetExecutingAssembly();

        private static readonly string Etag = $"W/\"{ExecutingAssembly.GetName().Version}\"";

        private static readonly ResourceManager Manager = new ResourceManager("MultiPlatformTextEditorCore.Resource", ExecutingAssembly);

        private static readonly List<string> EndpointKeys = new List<string>
        {
            "/",
            "/css/bootstrap.min.css",
            "/css/site.css",
            "/js/site.js",
            "/favicon.ico"
        };

        private static readonly Dictionary<string, (string resourceId, string mimeType, string size, bool isBinary)> Maps =
            new Dictionary<string, (string, string, string, bool)>();

        static ResourceMiddleware()
        {
            foreach (var key in EndpointKeys)
            {
                var resourceId = key == "/" ? "/index.html" : key;
                var (mimeType, isBinary) = resourceId.Split('.').Last() switch
                {
                    "html" => ("text/html; charset=UTF-8", false),
                    "css" => ("text/css; charset=UTF-8", false),
                    "js" => ("application/javascript; charset=UTF-8", false),
                    "ico" => ("image/x-icon; charset=UTF-8", true),
                    _ => throw new NotSupportedException()
                };
                if (isBinary)
                {
                    if (!(Manager.GetObject(resourceId) is byte[] content))
                    {
                        throw new NotSupportedException(key);
                    }
                    Maps[key] = (resourceId, mimeType, content.Length.ToString(), true);
                }
                else
                {
                    var content = Manager.GetString(resourceId);
                    var size = System.Text.Encoding.Default.GetByteCount(content);
                    Maps[key] = (resourceId, mimeType, size.ToString(), false);
                }
            }
        }

        public static void UseResourceMiddleware(this IApplicationBuilder app)
        {
            app.UseEndpoints(endpoints =>
            {
                foreach (var (key, value) in Maps)
                {
                    endpoints.MapGet(key, async context =>
                    {
                        var (resourceId, mimeType, size, isBinary) = value;
                        context.Response.Headers.Add("x-resource-middleware", "true");
                        context.Response.Headers.Add("content-type", new StringValues(mimeType));
                        if (key == "/")
                        {
                            context.Response.Headers.Add("cache-control", new StringValues(new[] { "no-cache", "no-store", "must-revalidate" }));
                            context.Response.Headers.Add("Expires", new StringValues("0"));
                        }
                        else
                        {
                            context.Response.Headers.Add("cache-control", new StringValues("max-age=3600"));
                        }
                        context.Response.Headers.Add("Connection", new StringValues("keep-alive"));
                        context.Response.Headers.Add("content-length", new StringValues(size));
                        context.Response.Headers.Add("etag", new StringValues(Etag));

                        if (!isBinary)
                        {
                            await context.Response.WriteAsync(Manager.GetString(resourceId));
                        }
                        else
                        {
                            await context.Response.BodyWriter.WriteAsync(Manager.GetObject(resourceId) as byte[]);
                            await context.Response.BodyWriter.FlushAsync();
                        }
                    });
                }
            });
        }
    }

I also added one more custom header called x-resource-middleware so I can inspect in my browser developer console that it is indeed served from resources.

And finally we inside Startup class we can instruct the application to use resource middleware instead of static files like this:

            //app.UseDefaultFiles();
            //app.UseStaticFiles();

            app.UseResourceMiddleware();

Now, when we run this application again, we will see custom header telling us that we are serving from embedded resources:

And application still works.

Building for Linux

Obviously I'm developing on Windows workstation. Now, I want to create an executable file from this source code that will run on Linux system.

First, we need to clean up the project to use only the current version of our files.

We target Release configuration with dotnet clean .\MultiPlatformTextEditorCore.csproj -c Release command.

PS C:\Users\vedran\Source\Repos\MultiPlatformTextEditor> dotnet clean .\MultiPlatformTextEditorCore.csproj -c Release
Microsoft (R) Build Engine version 16.4.0+e901037fe for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

Build started 14.1.2020. 14:00:05.

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:01.28
PS C:\Users\vedran\Source\Repos\MultiPlatformTextEditor>

And now, we can create an executable with publishing command that targets linux-x64.

linux-x64 is .NET Core Runtime IDentifier (RID) for targeting specific platform. Full list of RID's is liste in official .NET Core RID Catalog.

The full build command is dotnet publish -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishTrimmed=true -o "output\linux-x64" --nologo .\MultiPlatformTextEditorCore.csproj:

PS C:\Users\vedran\Source\Repos\MultiPlatformTextEditor> dotnet publish -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishTrimmed=true -o "output\linux-x64" --nologo .\MultiPlatformTextEditorCore.csproj                                                                                                                                          Restore completed in 177,76 ms for C:\Users\vedran\Source\Repos\MultiPlatformTextEditor\MultiPlatformTextEditorCore.csproj.
  MultiPlatformTextEditorCore -> C:\Users\vedran\Source\Repos\MultiPlatformTextEditor\bin\Release\netcoreapp3.0\linux-x64\MultiPlatformTextEditorCore.dll
  Optimizing assemblies for size, which may change the behavior of the app. Be sure to test after publishing. See: https://aka.ms/dotnet-illink
  MultiPlatformTextEditorCore -> C:\Users\vedran\Source\Repos\MultiPlatformTextEditor\output\linux-x64\
PS C:\Users\vedran\Source\Repos\MultiPlatformTextEditor>

This will create output\linux-x64 directory where build output will be written. After the build is finished it will contain all project-dependent files, but in this case, we only need one: MultiPlatformTextEditorCore binary.

Testing

Single file executable is successfully created, let's see will it work on Linux.

For this purpose, I use Ubuntu installation in Windows Subsystem for Linux (WSL). This installation does not have any .NET libraries or runtime installed:

vbilopav@DESKTOP-KT51PKL:~$ dotnet
dotnet: command not found
vbilopav@DESKTOP-KT51PKL:~$

Let's copy our single binary to Linux:

vbilopav@DESKTOP-KT51PKL:~$ cp /mnt/c/Users/vedran/source/repos/MultiPlatformTextEditor/output/linux-x64/MultiPlatformTextEditorCore .
vbilopav@DESKTOP-KT51PKL:~$ ls
MultiPlatformTextEditorCore
vbilopav@DESKTOP-KT51PKL:~$

Now I can run this with command telling it to create a new file for that I will then edit with my GUI editor: vbilopav@DESKTOP-KT51PKL:~$ ./MultiPlatformTextEditorCore test.txt

We see familiar dotnet output telling us that our application is listening on http://localhost:5000:

And finally, we can test our application we navigate to URL above, enter some text and click the save button:

Let's validate our test and examine the content of test.txt file:

It works, the test is successful.

We now have a fully functional text editor GUI application contained in a single executable file that can be built for multiple operations systems from single source code.

Final words

This was an interesting experiment to say at least.

However, there are a number of open questions about this concept such as for example:

  • File size
  • Security

etc.

But that doesn't mean that it can't be used to build useful GUI tools that can run on a server and target multiple operating systems.

What do you think?

Is it just an interesting experiment or it can be useful?