Table of Contents

Architecture

πŸ‡ͺπŸ‡Έ ΒΏPrefieres espaΓ±ol? Lee la versiΓ³n en espaΓ±ol.

MC Server Launcher is a Avalonia / .NET 9 desktop app that follows the MVVM pattern (using CommunityToolkit.Mvvm) with the Avalonia Fluent theme (cross-platform). It manages one or more Minecraft servers without .bat files, console windows or editing config files by hand.

Layers

The project (McServerLauncher/) is organized by responsibility:

Folder Responsibility
Models/ Plain data: persisted config (ServerConfig), settings (AppSettings), enums (ServerState, PlayitState).
Services/ All the logic with no UI: processes, files, network, Java, Playit, ports, etc. Each service is a small, focused class.
ViewModels/ The state and commands the UI binds to (MainViewModel, ServerViewModel). No Avalonia controls here, only ObservableObject/RelayCommand.
Views/ The .axaml windows/dialogs (Avalonia XAML) and their thin code-behind.
Localization/ The translation system (Localizer + {loc:Loc} markup extension).
Behaviors/ Attached behaviors (AutoScrollBehavior, MOTD coloring in MinecraftMotd).
Controls/ Custom controls (Sparkline for the CPU/RAM mini-charts).
Resources/ Strings*.resx (translations) and app.ico.

The single value converter, BoolOpacityConverter, lives in ViewModels/ β€” there is no Converters/ folder.

Data lives per user under %APPDATA%\McServerLauncher\:

  • servers.json β€” the server list and each server's config.
  • settings.json β€” global settings (language, Playit agent secret key, last-seen version…). Both JSON files are written atomically (AtomicJsonFile): the previous version is kept as .bak, and a corrupt file is quarantined as .bad and recovered from the .bak when possible (the user is warned at startup instead of silently losing the list).
  • java\ β€” Java runtimes the app installs (Temurin/Adoptium).
  • logs\ β€” the persistent console log (launcher-yyyy-MM-dd.log, pruned after 14 days).
  • .secret.key β€” the AES-GCM key that encrypts secrets on Linux/macOS (Windows uses DPAPI, so no key file there).

Each server's own folder also holds a backups\ directory with the automatic world backups. There are no hard-coded machine paths.

Key services

  • ServerProcessManager β€” owns the java process lifecycle: starts it (no console window), redirects stdin/stdout/stderr, re-emits each output line via an event, and stops it cleanly by sending stop (with a kill fallback).
  • JavaService β€” detects installed Java versions and, if none is compatible, downloads the right Temurin (Adoptium) JRE for the architecture. Used both when creating and when starting a server.
  • MinecraftVersionService β€” reads Mojang's version manifest, resolves the server.jar download URL and the required Java version, and downloads files.
  • PlayitApiService / PlayitPartnerService / PlayitManager β€” talk to Playit.gg. PlayitPartnerService runs the third-party setup-code flow (create_agent) to mint a per-user self-managed agent secret key from a code the user pastes. The partner Api-Key is never in the app (it's public + open-source): the call goes through a small proxy (a Cloudflare Worker, see playit-proxy/) that injects the key server-side. The Worker only accepts the create-agent POST shape the desktop app sends, rejects browser-origin traffic, and can rate-limit per IP. The variant_id/version are public and baked in. PlayitApiService then uses the returned per-user key (as agent-key, set app-wide via SetAgentKey) to list/create/delete tunnels β€” falling back to a legacy playit.toml secret or pasted write key otherwise. PlayitManager queries/starts/stops the background Windows/systemd service. PlayitConnection is the shared connect/disconnect flow used by the tunnel buttons and the Settings dialog.
  • PortService β€” checks which TCP ports are in use, finds a free one, and (via P/Invoke) finds the PID listening on a port so a stuck server can be freed.
  • ServerPropertiesService, PlayersService, WhitelistService β€” read/write the server's files (server.properties, ops.json, banned-players.json, whitelist.json).
  • ServerCreationService β€” writes the initial files of a new server: eula.txt, run.bat/user_jvm_args.txt and a minimal server.properties with the chosen port. (The jar download is done by MinecraftVersionService/ModLoaderService/PaperService and the port is picked by PortService, all orchestrated by CreateServerDialog.)
  • ServerTypeCatalog β€” one row per server type: display name, family (plugins/mods/neither), badge colour and its CrossplayLevel. The picker, the badges, the mod store, the content folder and the crossplay rules all read from it, so adding a type is a row rather than six switch statements found by hand. The level is three-valued rather than a yes/no, because "Geyser publishes a build" and "your friend on a phone can play" are different claims: Full for Paper, Purpur and Fabric, Partial for NeoForge β€” it connects and authenticates, and then any mod the client is required to have shuts Bedrock out β€” and None for Vanilla and Forge. CrossplayService.CaveatKey turns the level into the note both dialogs show.
  • ServerJarInstaller β€” the one place that knows how each type is obtained. The create dialog and the change-type dialog both call it; the chain used to be written out inline in both, and a type present in one and missing from the other silently produced a Vanilla server.
  • ModLoaderService / PaperService / PurpurService β€” install a mod loader (Fabric/Forge/NeoForge) or a Paper/Purpur build. Purpur publishes only an MD5 for its builds, not a SHA-256; HTTPS authenticates the source and the hash is there to catch a corrupted download, which is documented in the service itself. Also installs a loader onto an existing server, keeping the world. Known limitation: Fabric's meta endpoint publishes no checksums, so its server jar can't be hash-verified like the other sources (Mojang SHA-1, Paper SHA-256…); instead the downloaded jar is structurally validated (its install.properties must match the requested game/loader versions) and discarded on mismatch. Forge's trust assumption: its maven publishes a .sha1 next to each artifact but from the same server (no independent signatures exist in the Forge ecosystem), so the mandatory hash check protects against corruption, not a compromised server; since the installer is executed, it is additionally validated structurally (it must carry install_profile.json or an installer manifest) before java -jar ever sees it. NeoForge is the same shape with a better hash: its maven publishes a .sha256 beside each artifact, so that is what is checked. The trust assumption is unchanged β€” same server as the jar β€” and a missing hash still means no install, because what follows is java -jar. Which build belongs to which Minecraft version is decided by NeoForgeVersions, separately from the download: NeoForge has no promotions feed, so the mapping is derived from the build number and is unit-tested on its own.
  • ModrinthService β€” searches Modrinth and downloads mods/plugins (filtered by the server's type and version), and drives the "check for mod updates" flow.
  • ModDependencyService β€” walks a version's required dependencies, transitively, and says which are missing. Two facts about Modrinth's data shape it: dependencies carry no version range (a dependency either pins one version id or names a project), which is why "that project is already installed" is a complete answer rather than an approximation; and embedded means the dependency is already inside the jar, so installing it again produces the loader's duplicate mod failure. The walk itself (WalkAsync) takes its lookup as a delegate, so what it decides is tested against a table rather than against Modrinth on the day the test runs.
  • ContentManifest / ContentDependencyCheck β€” read what each jar declares about itself (what it provides and what it needs) and report what is missing. Three formats: fabric.mod.json, Bukkit's plugin.yml and Forge/NeoForge's mods.toml, with no YAML or TOML library β€” only lists of names are needed, and anything not understood counts as "declares nothing". No network, deliberately: this is the check that runs when Start is pressed, and the Modrinth calls that would answer the same question swallow their errors and return empty, so offline they would report nothing missing on the one screen where being wrong stops the server coming up. A test forbids those two files from mentioning HttpClient or ModrinthService.
  • NotificationCatalog / NotificationPalette β€” which level and which emoji each kind of notification gets, and what the default colours are. The same split as ServerTypeCatalog and ServerTypeBrushes: the UI-free data here, the Avalonia brushes in NotificationBrushes. The colours the user can change live in NotificationSettings, which is serialized to settings.json.
  • ConsoleLineClassifier / ConsoleColors β€” what each console line is about and what colour it is drawn in. The source outranks the text: the app's own messages are tagged where they are raised, because their text is localized β€” the [Launcher], [Error] and [Players] prefixes live inside the resx values β€” so a classifier keyed on them would work in Spanish and quietly stop working in German. stderr arrives tagged from ServerProcessManager, which used to merge it with standard output in one handler. Only stdout is read: vanilla's bracket (level in the second one, not the first) and Paper's.
  • ServerDetectionService β€” inspects a folder to figure out an existing server's type/version when the user adds one that already exists.
  • ServerIconService β€” generates a server's server-icon.png: takes any user image, crops it to a centered square and scales it to 64Γ—64 with SkiaSharp. (ServerViewModel.LoadIcon is what reads it back for the Minecraft-style view.)
  • WorldBackupService β€” creates and restores zip backups of a server's world folder (<server>/backups/), pruning old ones past the retention.
  • CrashReportService β€” reads crash-reports/*.txt to pull out the Description: line and show a human-readable reason for a crash. (The unexpected-exit detection is ServerProcessManager's UnexpectedExit event; the auto-restart logic lives in ServerViewModel.)
  • ConsoleLogService β€” mirrors every console line to %APPDATA%\McServerLauncher\logs\ so the history survives restarts (14-day retention).
  • ProcessStatsService β€” samples CPU/RAM of the running java process for the live stats and the Sparkline mini-charts.
  • ToastService β€” shows the app's own pop-up notifications β€” always-on-top Avalonia windows in the bottom-right corner (titled with the server's name), shown only when the app isn't in focus; they work even without OS notification support.
  • NotificationPreferences β€” decides which notifications are shown, combining the global settings (master switch + per-kind: join, leave, death/kill, crash, auto-restart-gave-up) with an optional per-server override (ServerConfig.UseCustomNotifications). Per-server overrides are cloned field-by-field from the global defaults so later changes don't share mutable state. DeathMessageDetector spots death/kill lines in the console for the deaths notification, requiring a valid player-name subject followed immediately by a known vanilla death phrase to reduce chat or plugin false positives.
  • SecretProtector β€” encrypts secrets at rest (DPAPI on Windows, AES-GCM + .secret.key on Linux/macOS), used for the Playit per-user agent secret key (and the legacy write key). If encryption ever fails, the key is not persisted (plaintext never lands on disk): it keeps working for the session, the failure goes to the daily log, and the user is warned once.
  • DownloadVerifier β€” the shared checksum verifier for downloads (Mojang SHA-1, Adoptium/Paper SHA-256, Modrinth SHA-512/SHA-1), deleting the file on mismatch.
  • Changelog β€” the per-version "what's new" notes shown after an update (see the flow below).
  • UpdateService β€” checks GitHub Releases for a newer version and downloads the installer for the in-app update. Verification against the release's SHA256SUMS.txt asset is mandatory: if the checksum is missing or unreadable, the silent install is refused and the release page opens instead.

Important flows

Starting a server

ServerViewModel.Start β†’ refresh port/info β†’ if the port is busy, offer to free it (PortService + TryFreePortAsync) β†’ EnsureCompatibleJavaAsync (uses JavaService to read the required Java from the jar and install it if needed) β†’ ServerProcessManager.Start. Console output streams back through the OutputReceived event into ConsoleLines.

Java auto-install

At create time, CreateServerDialog asks MinecraftVersionService for the required Java major and calls JavaService.EnsureJavaAsync. At start time, ServerViewModel reads the Java version embedded in server.jar (version.json) and installs/uses a compatible runtime, saving the path in ServerConfig.JavaPath.

Playit tunnel

First time the user connects Playit, MainViewModel.EnsurePlayitAgentAsync shows the setup-code dialog (opens playit.gg/l/setup-third-party only on a click), exchanges the pasted code via PlayitPartnerService.CreateAgentAsync for a per-user agent secret key, and stores it encrypted. When creating a server (or via the "Create tunnel" button), MainViewModel calls PlayitApiService.EnsureMinecraftTunnelAsync with that key. The public address is detected periodically by ServerViewModel via GetAddressForPortAsync, matching by local port. Compliance with Playit's third-party rules: the browser only opens on an explicit click, a disclaimer states the app is not affiliated with Playit, and the user can always reach their Playit account directly. A self-managed agent forwards traffic only while the agent process runs, so PlayitAgentRunner downloads Playit's official playitd binary (once, pinned to the registered version) and runs it as a hidden child process with --secret <the per-user key> while the app is open and connected β€” the user installs nothing. Since that native binary is the highest-privilege code the app fetches, it is verified against a hard-coded SHA-256 (of the exact pinned version) before it ever runs β€” on download and when reusing a cached copy β€” and deleted/failed on mismatch, just like every other download (DownloadVerifier). One agent serves all the user's tunnels. Not available on macOS (Playit ships no macOS binary); there the user runs Playit themselves.

In-app update + what's-new

On startup MainViewModel.CheckForUpdatesAsync asks UpdateService for the latest release and its installer asset. The Update button (UpdateNowCommand) downloads the installer, stops servers, runs it silently and exits; the installer reinstalls and relaunches the app. After an update, MainWindow.Loaded calls ShowWhatsNewIfUpdated, which compares the running version with AppSettings.LastVersionSeen and shows WhatsNewDialog (localized) with the notes from Changelog for every version the user hadn't seen yet.

World backups

WorldBackupService zips a server's world into <server>/backups/ on demand and automatically: before every start (the main safety net β€” it also covers Restart and auto-restart after a crash), after a manual clean stop, and before a restore. It keeps the most recent ones up to the configured retention. ServerBackupsView lists them and can restore any backup (taking a safety backup first).

Auto-restart after a crash

When a server exits unexpectedly, ServerProcessManager raises its UnexpectedExit event and ServerViewModel restarts it within a budget (a few attempts inside a stability window) to avoid crash loops, notifying the user via ToastService if the budget is exhausted. CrashReportService reads the server's crash report to add a human-readable reason to that notification.

System tray

App installs a TrayIcon. Minimizing keeps the window on the taskbar as usual; closing it with the X hides it to the tray (servers keep running) instead of quitting. The tray menu restores the window (Show) or really quits (Exit β†’ MainWindow.RequestExit, which runs the clean shutdown).

Checking mods/plugins for updates

ServerModsViewModel asks ModrinthService to identify each installed file on Modrinth and flag the ones with a newer version; the user updates each with one click (checksum-verified download via DownloadVerifier, preserving its enabled/disabled state).

The same scan answers a second question off the same hashes: which library mods are missing. GetVersionsByHashAsync says what each jar is (project id and declared dependencies, unlike the update endpoint, which says what could replace it), ModDependencyService works out what is required and absent, and the panel offers to install it. Installing a mod resolves its dependencies the same way, in the same click β€” which is the fix for a Fabric loader refusing to start over a fabric-api nobody was ever asked to install.

Localization

All user-facing text lives in Resources/Strings.resx (Spanish, the neutral/base language) plus satellite files Strings.en.resx, Strings.pt.resx, Strings.fr.resx, Strings.de.resx. Code reads them with Localizer.Get("Key") (and string.Format for parameters); XAML uses the {loc:Loc Key} markup extension. The active language comes from AppSettings.Language and is applied in App.OnFrameworkInitializationCompleted before any window is created, so changing the language requires a restart. See Contributing for how to add a language or a new string.