.NET Fundamentally Sucks for Cross-Platform
Tags: opensource, blog, csharp, linux
Published: 2023-11-20 09:53:42

_And some tips for your journey if you ever need to write a cross platform app._

Since 2020, [2009scape](2009scape.org) has supported singleplayer mode for those who want to play Runescape solo. But it involved downloading lots of random files and scripts to get running - Until today!

![Singleplayer tab in the Saradomin Launcher](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m8d3v8ffb4uxvw8eiwe2.png)

Building off Ceikry's singleplayer scripts and vddCore's .NET UI, this couldn't be easier - everything crossplatform is handled for me! Well, because .NET sucks, I couldn't be more wrong. 

## Downloading and Extracting Files

Downloading large files without freezing your UI is easy, but what if it needs preprocessing? No problem - Download to `System.IO.Path.GetTempPath()`.

**WRONG!** Processed results can't be reliably moved out, since `Directory.Move` doesn't work cross-partition. And many Linux filesystems (like BTRFS) joyfully split installations into partitions.

Second, we have a problem with the extracted files. `ZipFile` doesn't provide a way to extract the contents of a zip into a location (why would you ever want that?), so obviously we have to access the first folder inside the zip and copy that out. Obviously.

```C#
string tempDir = "singleplayer_temp"; // Can't use System.IO.Path.GetTempPath()!
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
await Task.Run(() => ZipFile.ExtractToDirectory(downloadPath, tempDir));
Directory.Move(Directory.GetDirectories(tempDir)[0], GetSingleplayerHome());
Directory.Delete(tempDir, true);
```


## Running .bat and .sh scripts from C#

Since the old singleplayer was distributed in shell scripts, calling them from C# on Linux and MacOS worked like a charm - It was plug 'n play with the following code:

```C#
Process process = new Process();
process.StartInfo = new ProcessStartInfo("sh", "-c singleplayer.sh")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true,
};

process.OutputDataReceived += (_, e) => onOutputReceived?.Invoke(e.Data);
process.ErrorDataReceived += (_, e) => onErrorReceived?.Invoke(e.Data);

process.Start();

process.BeginOutputReadLine();
process.BeginErrorReadLine();
```

![Linux Singleplayer Logs in UI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7eo4nb47ryapb64oeu5j.png)

Surely on Windows, using C# and .NET, you can run .bat files, right? **Absolutely not**. No amount of documentation, GPT prompting or aimlessly hacking could get the stdout and stderr logs to display in realtime. Windows instead needed it's .bat logic converted to C#, and lives in its own file. At least GPT is good for transpiling small chunks of code.

## Copying folders? Why would you want to do that?

Not only is .NET lacking a `cp -r`, the answers on [Stackoverflow](https://stackoverflow.com/questions/58744/copy-the-entire-contents-of-a-directory-in-c-sharp) and ChatGPT fail miserably on so many edge cases.

The following code will copy a directory, and:
* Keep the same file structure
* Create new folders as needed
* Work cross-platform and across partitions

```C#
public static void CopyDirectory(string sourceDir, string destinationDir)
{
    if (!Directory.Exists(destinationDir))
        Directory.CreateDirectory(destinationDir);

    foreach (string filePath in Directory.GetFiles(sourceDir))
    {
        File.Copy(filePath, Path.Combine(destinationDir, Path.GetFileName(filePath)), true);
    }

    foreach (string subDirPath in Directory.GetDirectories(sourceDir))
    {
        // Recurse!
        CopyDirectory(subDirPath, Path.Combine(destinationDir, Path.GetFileName(subDirPath)));
    }
}
```

This function is 3 lines [Code Golfed](https://en.wikipedia.org/wiki/Code_golf). Is that too much to include in default libraries, like Python, Perl or Go do?

## Where to put my data files?

Linux users are picky about apps vomiting files everywhere. To ease integrations into distribution methods like Flatpak or Snap (which remove the need of testing your app on 20+ different distros), as well as to avoid the infamous Arch Wiki [XDG Base Directory](https://wiki.archlinux.org/title/XDG_Base_Directory) list, follow the XDG standard about where your put your data:

```C#
public static string Get2009scapeDataHome()
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
    {
        return Path.Combine(
            Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share"),
            "2009scape");
    }

    return Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        "2009scape"
    );
}
```

## Unit Tests

When your code is doing something that will be a pain to test by hand, **write testable code**. Don't write code and then try to unit test it, think about how it can be tested first.

Good:

```C#
[Pure] public static string FindMostRecentBackupDirectory(IEnumerable<string> directories) {
    return directories.Select(Path.GetFileName).MaxBy(name => name);
}
// ...
string[] backupDirectories = Directory.GetDirectories(backupHome);
string mostRecentBackupDirName = FindMostRecentBackupDirectory(backupDirectories);
CopyFiles(mostRecentBackupDirName, singleplayerConfigDir);
```

This lets you test what you're worried about - Are you grabbing the right directory when you say "apply the most recent backup?"

> A [Pure Function](https://en.wikipedia.org/wiki/Pure_function) is a function that produces no tangible side effects. It is almost impossible to meaningfully unit test a non-pure function. **This is a plea to stop doing it.** Write functions that have the same output for the same input no matter what and unit test those.


Bad:

```C#
string[] backupDirectories = Directory.GetDirectories(backupHome);
string mostRecentBackupDirName = backupDirectories.Select(Path.GetFileName).MaxBy(name => name);
CopyFiles(mostRecentBackupDirName, singleplayerConfigDir);
```

You can't meaningfully test this. What are you going to do, have your test suits create files and folders, then every time you change the backup logic, you change your tests as well? All this does is duplicate code and test your FS.

## Closing Thoughts

C# isn't the best language, and cross-platform .NET isn't the best framework. The suboptimal technical side compliments the suboptimal [community respect](https://github.com/dotnet/maui/issues/35) side well.

However, it got the job done, and it didn't suck up 512MB of RAM to render UI - a monumental feat for us developers in 2023. By keeping expectations low, and writing numerous basic functions Microsoft should have included, we were able bundle singleplayer quite easily!

The final MR can be seen [here](https://gitlab.com/2009scape/Saradomin-Launcher/-/merge_requests/48).