DEV Community

Cover image for .NET Fundamentally Sucks for Cross-Platform
Dan G
Dan G

Posted on

.NET Fundamentally Sucks for Cross-Platform

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

Since 2020, 2009scape 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

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.

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);
Enter fullscreen mode Exit fullscreen mode

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:

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();
Enter fullscreen mode Exit fullscreen mode

Linux Singleplayer Logs in UI

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 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
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)));
    }
}
Enter fullscreen mode Exit fullscreen mode

This function is 3 lines Code Golfed. 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 list, follow the XDG standard about where your put your data:

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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:

[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);
Enter fullscreen mode Exit fullscreen mode

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 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:

string[] backupDirectories = Directory.GetDirectories(backupHome);
string mostRecentBackupDirName = backupDirectories.Select(Path.GetFileName).MaxBy(name => name);
CopyFiles(mostRecentBackupDirName, singleplayerConfigDir);
Enter fullscreen mode Exit fullscreen mode

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 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.

Top comments (0)