Newer
Older
EARS-LINUX / Readme.md

Mono + GTK# + Glade — WSL Setup Guide

Table of Contents


Mono

1. Prerequisites

  • Windows 11 (or updated Windows 10) with WSL2 and WSLg enabled for GUI support
  • Ubuntu 24.04 (noble) or similar WSL distro

Check WSLg is active:

echo $DISPLAY

Should return something like :0. If empty, GUI apps won't display.


2. Install Mono

sudo apt update
sudo apt install mono-complete

Verify:

mono --version
mcs --version

3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)

Note: gtk-sharp2 is not available on Ubuntu 24.04 (noble). Use gtk-sharp3 instead.

sudo add-apt-repository universe
sudo apt update
sudo apt install gtk-sharp3

Verify the package is registered:

pkg-config --list-all | grep gtk-sharp

Expected output: gtk-sharp-3.0 Gtk - Gtk

4. Install Glade (visual UI designer)

sudo apt install glade

Launch it:

glade

Design your UI visually, then save as yourfile.glade (XML format).


5. Optional: libgdiplus (only if using System.Drawing)

Only needed if your code uses System.Drawing.Bitmap, Graphics, etc. (not required for plain GTK#/Glade apps):

sudo apt install libgdiplus

6. Compiling & Running

Plain console C# program

mcs myprogram.cs
mono myprogram.exe

GTK# 3.0 program (no Glade)

mcs -pkg:gtk-sharp-3.0 myapp.cs
mono myapp.exe

GTK# 3.0 program using a Glade file

mcs -pkg:gtk-sharp-3.0 -pkg:glade-sharp-3.0 myapp.cs
mono myapp.exe

Make sure the .glade file referenced in code sits in the same folder (or update the path in new Builder("...")).


7. Minimal Working Examples

Hello World (console)

using System;

class Hello {
    static void Main() {
        Console.WriteLine("Hello from Mono in WSL!");
    }
}
mcs hello.cs
mono hello.exe

Hello World (GTK# window, no Glade)

using System;
using Gtk;

class GtkCheck {
    static void Main() {
        Application.Init();
        var win = new Window("Hello GTK#3");
        win.SetDefaultSize(300, 200);
        win.DeleteEvent += (o, args) => Application.Quit();

        var button = new Button("Click me");
        button.Clicked += (o, args) => Console.WriteLine("Clicked!");
        win.Add(button);

        win.ShowAll();
        Application.Run();
    }
}
mcs -pkg:gtk-sharp-3.0 gtkcheck.cs
mono gtkcheck.exe

Loading a UI built in Glade

using System;
using Gtk;

class Program {
    static void Main() {
        Application.Init();
        var builder = new Builder("myui.glade");
        builder.Autoconnect(new Program());

        var win = (Window)builder.GetObject("window1");
        win.DeleteEvent += (o, args) => Application.Quit();
        win.ShowAll();

        Application.Run();
    }
}
mcs -pkg:gtk-sharp-3.0 -pkg:glade-sharp-3.0 program.cs
mono program.exe

8. Custom Output Names, Targets & Exporting DLLs

By default, mcs file.cs names the output after the source file (file.exe). You can control this with -out: and change what kind of binary is produced with -target:.

8.1 Custom output name

mcs -pkg:gtk-sharp-3.0 Program.cs -out:MyCustomApp.exe

Run it the same way:

mono MyCustomApp.exe

8.2 -target options

TargetProducesNotes
exe (default)Console executableShows a console window when run on Windows
winexeWindows GUI executableNo console window — correct choice for GTK#/Glade GUI apps distributed on Windows
library.dll class libraryNo Main entry point required to run directly; used by other programs
module.netmoduleRarely used; for combining into a single assembly later

Your example, building a windowed GUI executable with a custom name:

mcs -target:winexe -pkg:gtk-sharp-3.0 Program.cs -out:programwin.exe

This produces programwin.exe, which on Windows will run without popping up a console window alongside your GTK window.

8.3 Exporting a DLL

If you want to package reusable code (helper classes, business logic, etc.) as a library instead of a standalone app:

mcs -target:library Program.cs -out:MyLibrary.dll
  • No Main() method is required in a library target (though it's fine if one class in your project has one — it just won't be used as an entry point for the DLL itself).
  • This creates MyLibrary.dll, a Mono/.NET assembly that other C# programs can reference.

8.4 Using a DLL in another program

Suppose MyLibrary.dll contains:

// MyLibrary.cs
namespace MyLib {
    public class Greeter {
        public string Greet(string name) => $"Hello, {name}!";
    }
}

Compiled with:

mcs -target:library MyLibrary.cs -out:MyLibrary.dll

Then in a separate consumer program:

// ConsumerApp.cs
using System;
using MyLib;

class ConsumerApp {
    static void Main() {
        var greeter = new Greeter();
        Console.WriteLine(greeter.Greet("World"));
    }
}

Compile it while referencing the DLL with -r::

mcs ConsumerApp.cs -r:MyLibrary.dll -out:ConsumerApp.exe

Run it (make sure MyLibrary.dll is in the same folder as ConsumerApp.exe, or on Mono's library search path):

mono ConsumerApp.exe

8.5 Combining -target:library with GTK# packages

If your DLL itself uses GTK# types (e.g., a shared custom widget), include the package flag when building the library too:

mcs -target:library -pkg:gtk-sharp-3.0 MyGtkWidgets.cs -out:MyGtkWidgets.dll

And reference both the package and the DLL when compiling the consumer:

mcs -pkg:gtk-sharp-3.0 ConsumerApp.cs -r:MyGtkWidgets.dll -out:ConsumerApp.exe

8.6 Quick reference

# Console exe, custom name
mcs Program.cs -out:myapp.exe

# Windowed (no console) exe, custom name
mcs -target:winexe -pkg:gtk-sharp-3.0 Program.cs -out:programwin.exe

# Class library (DLL)
mcs -target:library Library.cs -out:MyLibrary.dll

# Program referencing that DLL
mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe

8.7 Running the compiled .exe on Windows (outside WSL)

Compiling in WSL produces a .exe that targets the .NET/Mono runtime — it is not a native Windows binary, and it will not run on Windows by itself. Two things are needed:

1. Install a runtime on the Windows machine

Pick one:

  • Mono runtime for Windows — install the Mono for Windows package so .exe files built with mcs can run via the Mono runtime. After install, you can either double-click the .exe (Mono associates itself with .exe files) or run it explicitly:
  mono programwin.exe
  • .NET Framework / .NET runtime — for simple GTK#-free console apps, the .exe may also run under an installed .NET runtime, but GTK# apps specifically require Mono's GTK# libraries, so the Mono for Windows install is the reliable option.

2. Copy the required DLLs alongside the .exe

Your compiled .exe references managed DLLs at runtime (not just at compile time). Anything you referenced with -r: or -pkg: must physically travel with the .exe:

  • Any custom library you built, e.g. MyLibrary.dll or MyGtkWidgets.dll
  • GTK# binding assemblies your app depends on (typically already provided by installing Mono for Windows, since it bundles the GTK# runtime — but if you used a nonstandard package, copy its DLL too)
  • Your .glade file, if the app loads one via Builder("myui.glade") — this isn't a DLL but is just as required; it must sit next to the .exe at the same relative path your code expects

Minimum folder layout to copy over to Windows: programwin.exe MyLibrary.dll (any custom DLLs you referenced with -r:) myui.glade (if using Glade)

Common pitfall: a program that runs fine in WSL but fails on Windows with FileNotFoundException or Could not load file or assembly almost always means a referenced DLL (or the .glade file) wasn't copied over, or Mono for Windows isn't installed.


9. Common Errors & Fixes

ErrorCauseFix
File does not contain a valid CIL imageRan mono on .cs source instead of compiled .exeCompile first: mcs file.cs, then mono file.exe
CS0246: type or namespace 'Gtk' could not be foundMissing package reference during compileAdd -pkg:gtk-sharp-3.0 to mcs command
CS0103: name 'Console' does not existMissing using System;Add using System; at top of file
Unable to locate package gtk-sharp2Package dropped in Ubuntu 24.04Use gtk-sharp3 instead
DllNotFoundException: libgdiplusCode uses System.Drawing without the lib installedsudo apt install libgdiplus
Could not load file or assembly 'MyLibrary.dll'DLL not in same folder / not foundPlace DLL next to the .exe, or set MONO_PATH
FileNotFoundException / Could not load file or assembly (on Windows)Runtime not installed, or referenced DLL/.glade not copied alongside the .exeInstall Mono for Windows; copy all -r: DLLs and .glade files into the same folder as the .exe

10. Notes on Alternatives

GTK# is a legacy, lightly-maintained binding. For new projects, consider:

  • Avalonia UI — modern, XAML-based, cross-platform, actively maintained
  sudo apt install dotnet-sdk-8.0
  dotnet new install Avalonia.Templates
  dotnet new avalonia.app -o MyApp

Use GTK#/Mono/Glade for legacy projects or learning; use Avalonia + dotnet for anything new and long-term.

Personal note:

Compile note: to compile the cs file mcs NAME_OF_THE_FILE.cs for GTK appilcation mcs -pkg:gtk-sharp-3.0 NAME_OF_THE_FILE.cs custom output name / windowed exe mcs -target:winexe -pkg:gtk-sharp-3.0 NAME_OF_THE_FILE.cs -out:CUSTOM_NAME.exe export as DLL mcs -target:library NAME_OF_THE_FILE.cs -out:CUSTOM_NAME.dll use a DLL in another program mcs Consumer.cs -r:CUSTOM_NAME.dll -out:Consumer.exe

to run note: same for all cases mono NAME_OF_THE_FILE.exe

OS

To test the application/setup across different Linux distributions, I used Ventoy to create a multiboot USB drive. Ventoy lets you copy multiple ISO files onto a single USB stick and choose which one to boot at startup, without needing to reformat or re-flash the drive for each OS.

Workflow

  1. Install Ventoy on the target USB drive.
  2. Copy the desired .iso files directly onto the Ventoy partition (no extraction needed).
  3. Boot from the USB, select the ISO from the Ventoy boot menu, and test the OS live or install it.

OS images checked

  • Fedora-Workstation-Live-44-1.7.x86_64
  • lubuntu-26.04-desktop-amd64
  • xubuntu-25.10-desktop-amd64

OS images to check

  • antiX-26_x64-full
  • xubuntu-26.04-minimal-amd64

EARS Application

Notes covering the GTK# port of the auscultation trainer: dynamic UI generation from CSV, the notebook page structure, and the failure modes that cost the most time.


11. Project Layout

Source lives in program/, build output in program/bin/linux/. Runtime assets must sit next to the .exe, not next to the source.

program/
├── Program.cs
├── MainWindow.cs              root window + splash page
├── SettingWindow.cs           settings page controller
├── SoundWindow.cs             sound page controller
├── CaseDefinition.cs          CSV row model + loader
├── WavePlayer.cs              cross-platform wav playback
├── ArduinoConnection.cs
├── Language.cs
├── homepageallv3.glade
├── cases.csv
├── map/
├── sound/
└── bin/linux/                 ← everything above (minus .cs) copied here
    ├── program.exe
    ├── homepageallv3.glade
    ├── cases.csv
    ├── map/
    └── sound/

12. Single-Window Notebook Architecture

One GtkWindow (Root) holds one GtkNotebook (RootNoteBook) with tabs hidden. Each "screen" is a notebook page; navigation is just RootNoteBook.CurrentPage = N. No second window is ever created.

PageIndexRoot widgetController
Splash0SplashGridMainWindow
Settings1SettingGirdSettingsWindow
Sound2SoundBoxSoundWindow

SettingsWindow and SoundWindow are not Gtk.Window subclasses despite the names — they're plain controller classes that receive the shared Builder and bind their own widgets:

public class SoundWindow
{
    [UI] private Label  ConditionNameLabel = null;
    [UI] private Grid   SoundButtonGrid    = null;
    [UI] private Button SoundBackButton    = null;

    public event EventHandler BackRequested;

    public SoundWindow(Builder builder)
    {
        builder.Autoconnect(this);
        ...
    }
}

Wired up in MainWindow's private constructor:

_soundWindow = new SoundWindow(builder);
_soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash;
Option1Button.Clicked += OnNormalSessionClicked;

Controllers never touch the notebook directly — they raise BackRequested and let MainWindow decide. Keeps navigation in one place.

All three controllers share one Builder. Each Autoconnect call binds only the ids matching that class's [UI] fields, which is exactly why ids must be unique across the entire file (§13).


13. Glade Rules That Bite

IDs must be unique file-wide and valid C# identifiers

Autoconnect maps glade ids to field names by string match across the whole builder, not per page. Two widgets sharing an id bind unpredictably.

BrokenWhyFixed
Condition NameSpace — never matches a field nameConditionNameLabel
BackButtoTypoSoundBackButton
BackButton on two pagesDuplicate across pagesSettingBackButton + SoundBackButton

A mismatch fails silently — the field stays null and you get a NullReferenceException later, often several clicks away from the cause. See §19 for the guard that catches this at startup.

Verify before running:

grep -o 'id="[^"]*"' homepageallv3.glade | sort | uniq -d

Any output is a duplicate id.

Placeholders are design-time only

The hatched empty cells Glade shows in a GtkGrid save as <placeholder/> and are ignored at load time. A button placed at left-attach=2 in a designer grid with two empty columns to its left ends up at column 0 in the running app — this is the classic "button jumps to the left" bug.

Never use placeholders for spacing or alignment. Use halign + hexpand.

Right-aligning a button

Both properties are required:

<property name="halign">end</property>
<property name="hexpand">True</property>

hexpand makes the cell consume the full row width; halign=end parks the button at the right edge of that cell. halign alone does nothing when the cell is only as wide as the button.

In Glade: Common tab → Horizontal Alignment = End, Expand → Horizontal = checked.

Scrolling a grid needs a Viewport

GtkGrid doesn't implement GtkScrollable. Dropping one into a GtkScrolledWindow requires an intermediate GtkViewport — Glade inserts it automatically. Don't delete it.

SoundScroller     GtkScrolledWindow   hexpand + vexpand, packing expand=True
└─ GtkViewport                        (auto-added, required)
   └─ SoundButtonGrid  GtkGrid        empty, column-homogeneous=True

GtkFlowBox is scrollable and wraps children automatically, but the gtk-sharp3 binding on Ubuntu is 2.99.x and may not expose it. Check before relying on it:

monop -r:/usr/lib/cli/gtk-sharp-3.0/gtk-sharp.dll Gtk.FlowBox

Expand appears in two tabs

For a widget inside a GtkBox, Common → Expand sets the widget's own hexpand/vexpand, while Packing → Expand sets the box child property. They are different things and both usually need setting.


14. Generating Widgets From Code

Leave the container empty in Glade and fill it at runtime. Three rules, all of which produce silent failures when broken:

private void BuildButtons(IEnumerable<string> labels, Action<string> onPick)
{
    // 1. Remove AND destroy — Remove alone leaks the widget
    foreach (var child in SoundButtonGrid.Children)
    {
        SoundButtonGrid.Remove(child);
        child.Destroy();
    }

    int i = 0;
    foreach (string text in labels)
    {
        // 2. Capture the loop variable — otherwise every handler
        //    sees the final value
        string captured = text;

        var btn = new Button(captured);
        btn.Hexpand = true;
        btn.Clicked += (s, e) => onPick(captured);
        SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1);
        i++;
    }

    // 3. Widgets created in code start HIDDEN. Without this the grid
    //    stays blank with no error of any kind.
    SoundButtonGrid.ShowAll();
}

ShowAll() on the container is the single most common cause of "my buttons didn't appear" in GTK#.

Drill-down navigation

All three modes (type → condition → play) use one grid and one render method. State is a List<string> path; Back pops one level:

private readonly List<string> _path = new List<string>();

private void Render()
{
    var matches = _allCases.Where(MatchesPath).ToList();

    var options = matches
        .Where(c => c.TreePath.Length > _path.Count)
        .Select(c => c.TreePath[_path.Count])
        .Distinct()
        .ToList();

    ConditionNameLabel.Text = _path.Count == 0
        ? "種別を選択"
        : string.Join(" : ", _path);

    BuildButtons(options, picked => { _path.Add(picked); /* leaf? play : Render(); */ });
}

private void OnBackClicked(object sender, EventArgs e)
{
    if (_path.Count > 0) { _path.RemoveAt(_path.Count - 1); Render(); }
    else { WavePlayer.Stop(); BackRequested(this, EventArgs.Empty); }
}

Tree depth varies per row (Tree_Level3 is often empty), so CaseDefinition.TreePath returns only the non-empty levels and the drill-down adapts automatically.


15. Loading cases.csv

CaseDefinition.LoadCasesFromCsv(path) returns List<CaseDefinition>. Column → property mapping:

CSV columnProperty
NumberNumber
TypeType (心音 / 呼吸音) — also drives IsHeart
CategoryCategoryJp
SubcategorySubcategoryJp
LocationLocationJp
Tree_Level1..3TreeLevel1..3
Image_FileMapFront
Sound_FileSoundPath
Image_Right / Image_Left / Image_BackMapRight / MapLeft / MapBack

Four things the loader must get right:

Encoding. The file contains Japanese. Read with Encoding.UTF8 and BOM detection — the default ANSI codepage produces mojibake that only shows up on the rendered buttons.

Delimiter. The file is TAB separated. Auto-detect so a re-export from Excel as comma-separated doesn't break it:

char sep = lines[0].Contains("\t") ? '\t' : ',';

Header mapping. Build name → index from row 0 rather than hardcoding positions, so adding or reordering columns is safe.

InvariantCulture on every numeric parse. Same failure class as the CSS scaling bug in §"scaling" — under ja_JP/de_DE the culture-sensitive default silently misparses:

int.TryParse(get(f, "Number"), NumberStyles.Integer,
             CultureInfo.InvariantCulture, out number)

The loader prints its result on every run:

Loaded 98 cases from /path/to/bin/linux/cases.csv

Loaded 0 cases points at the path or the delimiter, not the UI.


16. File Paths — AppFile()

Never use bare relative paths. Path.GetFullPath("cases.csv") resolves against the working directory, so the app works when launched from program/ and silently loads nothing from anywhere else.

public static string AppFile(string relative)
{
    string dir = Path.GetDirectoryName(
        System.Reflection.Assembly.GetExecutingAssembly().Location);
    return Path.Combine(dir, relative);
}

Use it for everything: the glade file, the CSV, maps, sounds.

builder.AddFromFile(SoundWindow.AppFile("homepageallv3.glade"));

Mixing the two conventions is how you end up editing program/homepageallv3.glade while the app reads bin/linux/homepageallv3.glade — every fix appears to do nothing.

Linux is case-sensitive. sound/snd200.wavsound/SND200.wav, which was fine on Windows and isn't now. Audit the CSV against the disk:

cut -f10 cases.csv | tail -n +2 | while read f; do
  [ -n "$f" ] && [ -f "$f" ] || echo "MISSING: $f"
done

17. Audio Playback

XAudio2/SharpDX from the WinForms build does not load under Mono on Linux. WavePlayer shells out instead:

PlatformMechanism
Linux / WSLpaplay, falling back to aplay
WindowsSystem.Media.SoundPlayer

Install both helpers — WSLg routes through PulseAudio, so paplay is the one that works:

sudo apt install pulseaudio-utils alsa-utils

Verify outside the app before blaming the C#:

paplay bin/linux/sound/SND200.wav
echo $PULSE_SERVER          # empty under WSLg → wsl --shutdown and retry

Format matters on Windows. SoundPlayer handles PCM WAV only:

file sound/SND200.wav       # want: RIFF ... WAVE audio, Microsoft PCM

Stop() is a no-op on WindowsSoundPlayer.Play() returns no handle. If stop-on-back is needed there, use PlaySync() on a background thread or add NAudio.


18. Build & Run

Debug build (keeps the console so Console.WriteLine diagnostics show):

mcs -pkg:gtk-sharp-3.0 *.cs -out:./bin/linux/program.exe \
  && cp homepageallv3.glade cases.csv ./bin/linux/ \
  && cp -r map sound ./bin/linux/ \
  && mono ./bin/linux/program.exe

The cp steps are not optional — stale assets in bin/linux/ are a recurring source of phantom bugs (§16).

Re-copying every wav on each build gets slow. Once stable, symlink and drop the cp -r:

ln -s ../../map   bin/linux/map
ln -s ../../sound bin/linux/sound

Release build (-target:winexe suppresses the console — don't use it while debugging):

mcs -target:winexe -pkg:gtk-sharp-3.0 *.cs -out:./bin/windows/program.exe

19. Debugging Autoconnect Failures

An unbound [UI] field is null with no warning. Add an explicit guard to every controller constructor so the failure names the widget at startup:

builder.Autoconnect(this);

if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null)
    throw new InvalidOperationException(
        "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) +
        " SoundButtonGrid=" + (SoundButtonGrid != null) +
        " SoundBackButton=" + (SoundBackButton != null));

Output looks like:

System.InvalidOperationException: Glade id mismatch —
  ConditionNameLabel=False SoundButtonGrid=True SoundBackButton=True

For classes with many fields, loop a dictionary instead:

foreach (var pair in new Dictionary<string, object> {
    { "ComPortLabel", ComPortLabel }, { "ConnectButton", ConnectButton },
    { "SettingBackButton", SettingBackButton }, /* ... */ })
    if (pair.Value == null)
        throw new InvalidOperationException("Glade id not bound: " + pair.Key);

Reading a GTK# stack trace

Exceptions inside signal handlers arrive wrapped:

System.Reflection.TargetInvocationException ---> System.NullReferenceException
  at SoundWindow.Render () [0x0007b]

Ignore the GLib.SignalClosure / MarshalCallback frames — they're plumbing. The first frame naming your class is the real site, and the [0x...] IL offset distinguishes lines within it.

Common failures

SymptomCauseFix
NRE in controller ctor[UI] field id mismatch§19 guard, then fix the glade id
NRE on first click, no CSV log lineController never constructedAssign it in the private MainWindow(Builder) ctor
Buttons don't appear, no errorMissing ShowAll()Call it on the container after Attach
Every button does the same thingClosure over loop variablestring captured = text;
Loaded 0 casesWrong path or delimiterUse AppFile(); check tab vs comma
Button sits left despite halign=endRelying on grid placeholdersAdd hexpand=True
Fix appears to do nothingEditing a different copy of the gladeUse AppFile() + cp on build
Japanese renders as garbageWrong encodingFile.ReadAllLines(path, Encoding.UTF8)
GTK warning about scrollingGrid directly in ScrolledWindowKeep the GtkViewport

20. Windows Deployment Differences

The C#, the glade file, and the CSV are identical. Four differences:

  1. Audio — see §17. PCM-only, and Stop() doesn't work.
  2. Build target-target:winexe for no console window.
  3. Runtime — Mono for Windows must be installed on the target machine; it bundles the GTK# runtime.
  4. Serial portsArduinoConnection enumerates COM* rather than /dev/ttyUSB* / /dev/ttyACM*. Mono's Linux SerialPort.GetPortNames() has historically missed devices; verify against ls /dev/tty*.

Ship this folder:

program.exe
homepageallv3.glade
cases.csv
map/
sound/

Not problems, for the record: forward slashes in CSV paths work fine on Windows, and Windows' case-insensitivity means anything working on Linux also works there — never the reverse. Develop on Linux and Windows comes free.