Compare commits

...

5 Commits

Author SHA1 Message Date
Tim Krampitz
8078ac2212 Merge branch 'master' of https://git.krampitz.win/tim/SlipItIn 2026-08-22 19:07:15 +02:00
Tim Krampitz
540cda102f Support Android emulator API access
Enable CORS in all environments and disable HTTPS redirect in dev.
Seed the database after migrations.
Make the API base URL configurable, defaulting to the Android
emulator host, and use per-config Android manifests.
2026-08-22 19:05:53 +02:00
Tim Krampitz
3296ecdfb3 JWT- und Session-Validierung verbessert, UI aktualisiert
- JWT-Authentifizierung prüft nun, ob User aktiv und existent ist
- Session-Validierung beim App-Start via ValidateSessionAsync
- Ungültige Sessions werden entfernt, SignalR-Verbindung getrennt
- ApiService und IApiService um ValidateSessionAsync erweitert
- LoginViewModel nutzt Session-Check und behandelt SignalR-Fehler
- UI für eigene Phrasen auf <Border> mit Stil-Anpassung umgestellt
2026-08-08 21:30:18 +02:00
Tim Krampitz
6f3c0eb716 Synchronisierungs- und Verbindungsstatus verbessert
Neuer Nachrichtentyp SyncStateChangedMessage zur Übermittlung des Sync-Status über das MVVM-Messaging-System eingeführt. GameStateService sendet nach relevanten Aktionen den aktuellen Sync-Status. Methoden zur Synchronisierung sind jetzt thread-sicher und melden Statusänderungen laufend. Fehler beim Senden von Queue-Aktionen werden einzeln behandelt und führen zu Fehlermeldungen im Messenger. ViewModels aktualisieren neue Properties (IsSyncing, SyncStatusText, PendingSyncActions) bei Statusänderungen. XAML-Seiten zeigen Verbindungs- und Sync-Status sowie Offline-Aktionen an. SignalRService meldet Verbindungsstatus konsistenter, auch bei Fehlern und Disconnects.
2026-08-08 21:23:27 +02:00
Tim Krampitz
0f9cc1703a openwiki-update.yml: Actions & Schutzmechanismus verbessert
- Actions checkout und setup-node auf neuere Versionen aktualisiert
- fetch-depth: 0 für vollständige Git-Historie gesetzt
- npm-Installationsbefehl für openwiki vereinfacht (immer neueste Version)
- Schritt „Restore protected workflow file“ hinzugefügt, um Überschreiben durch OpenWiki zu verhindern
- Kleine Anpassung im PR-Body (Leerzeile entfernt)
2026-08-08 21:19:43 +02:00
18 changed files with 357 additions and 45 deletions

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Data;
/// <summary>
/// Befüllt die Datenbank mit Testdaten (2 Testuser, einige Dutzend Phrasen),
/// wenn sie leer ist. Dient der Erleichterung manueller Tests.
/// </summary>
public static class DbSeeder
{
public static async Task SeedAsync(SlipItInDbContext context)
{
// Nur seeden, wenn die Datenbank leer ist
if (await context.Users.AnyAsync() || await context.Phrases.AnyAsync())
return;
var alice = new User
{
Username = "alice",
Email = "alice@test.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
};
var bob = new User
{
Username = "bob",
Email = "bob@test.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
};
context.Users.AddRange(alice, bob);
await context.SaveChangesAsync();
var phrases = new[]
{
"Das hat mir meine Oma schon immer gesagt.",
"Ich hab da mal eine Doku gesehen.",
"Das ist wie damals in der Grundschule.",
"Das muss man sich mal auf der Zunge zergehen lassen.",
"Da kommt mir direkt ein Lied in den Sinn.",
"Das ist wie beim Autofahren: Bremsen kann man immer noch.",
"Ich habe das Gefühl, wir reden aneinander vorbei.",
"Das würde ich an deiner Stelle nochmal überdenken.",
"Ich glaube, da steckt mehr dahinter.",
"Also ich finde, das riecht verdächtig nach Montag.",
"Das schmeckt nach mehr.",
"Da ist der Wurm drin.",
"Das hat hand und Fuß.",
"Das ist ein zweischneidiges Schwert.",
"Da liegt der Hase im Pfeffer.",
"Das ist wie ein guter Wein wird mit den Jahren besser.",
"Ich würde sagen: Glück im Unglück.",
"Das ist schon fast eine Wissenschaft für sich.",
"Man muss das Kind beim Namen nennen.",
"Das ist wie Weihnachten und Geburtstag zusammen.",
"Da geht mir das Herz auf.",
"Ich würde das jetzt nicht überbewerten.",
"Das kommt mir spanisch vor.",
"Das ist das Beste seit geschnittenem Brot.",
"Da bleibt kein Auge trocken.",
"Das ist wie Nägel mit Köpfen machen.",
"Ich habe da so eine Vermutung.",
"Das ist wie ein roter Faden.",
"Da platzt mir gleich der Kragen.",
"Ich bin da ganz bei dir.",
"Das muss man erstmal sacken lassen.",
"Das ist wie ein offenes Buch.",
"Da würde ich nicht drauf wetten.",
"Das ist wie eine Achterbahn der Gefühle.",
"Ich hab da ein ganz mieses Gefühl bei.",
"Das ist wie ein Dorn im Auge."
};
var random = new Random(42);
var creators = new[] { alice, bob };
foreach (var text in phrases)
{
context.Phrases.Add(new Phrase
{
Text = text,
CreatorId = creators[random.Next(creators.Length)].Id
});
}
await context.SaveChangesAsync();
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
using SlipItIn.Server.Data;
using SlipItIn.Server.Hubs;
using SlipItIn.Server.Services;
using System.Security.Claims;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
@@ -50,6 +51,24 @@ builder.Services
context.Token = accessToken;
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var userIdClaim = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(userIdClaim, out var userId))
{
context.Fail("Invalid user claim.");
return;
}
var dbFactory = context.HttpContext.RequestServices.GetRequiredService<IDbContextFactory<SlipItInDbContext>>();
await using var db = await dbFactory.CreateDbContextAsync();
var userExists = await db.Users.AnyAsync(u => u.Id == userId && u.IsActive);
if (!userExists)
{
context.Fail("User no longer exists or is inactive.");
}
}
};
});
@@ -79,21 +98,28 @@ var app = builder.Build();
// Aspire Default Endpoints (Health Checks)
app.MapDefaultEndpoints();
// Migrations anwenden
// Migrations anwenden und Testdaten seeden (nur wenn die Datenbank leer ist)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
db.Database.Migrate();
await DbSeeder.SeedAsync(db);
}
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseCors("AllowAll");
}
app.UseHttpsRedirection();
app.UseCors("AllowAll");
// Beim Emulator/geräteübergreifenden Zugriff würde die HTTPS-Umleitung
// den Client auf eine nicht vertrauenswürdige Dev-Zertifikats-URL schicken.
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -35,17 +35,27 @@ public partial class App : Application
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
var apiService = ServiceHelper.GetRequiredService<IApiService>();
await authSession.InitializeAsync();
await gameStateService.InitializeAsync();
if (!string.IsNullOrWhiteSpace(authSession.AccessToken))
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
return;
var isSessionValid = await apiService.ValidateSessionAsync();
if (!isSessionValid)
{
await authSession.ClearSessionAsync();
await gameStateService.ClearLocalGameDataAsync();
await signalR.DisconnectAsync();
return;
}
var connected = await signalR.ConnectAsync(authSession.AccessToken);
if (connected)
await gameStateService.ResyncAsync();
}
}
catch
{
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.

View File

@@ -0,0 +1,12 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public sealed record SyncStateInfo(bool IsSyncing, string StatusText, int PendingActions);
public class SyncStateChangedMessage : ValueChangedMessage<SyncStateInfo>
{
public SyncStateChangedMessage(SyncStateInfo value) : base(value)
{
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
<application
android:allowBackup="true"
android:icon="@mipmap/appicon"
android:supportsRtl="true"
android:label="Slip It In"
android:usesCleartextTraffic="true" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
<application
android:allowBackup="true"
android:icon="@mipmap/appicon"
android:supportsRtl="true"
android:label="Slip It In">
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true" android:label="Slip It In"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -28,6 +28,23 @@ public class ApiService : IApiService
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/login", request, cancellationToken);
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
return false;
try
{
AttachAuthHeader();
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public Task LogoutAsync() => _authSession.ClearSessionAsync();
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)

View File

@@ -4,9 +4,20 @@ namespace SlipItIn.Services;
public class AppConfigurationService : IAppConfigurationService
{
private const string ApiBaseUrlPreferenceKey = "api_base_url";
private const string DefaultApiBaseUrl = "https://localhost:7274";
private const string AndroidEmulatorApiBaseUrl = "http://10.0.2.2:5234";
public string ApiBaseUrl => DefaultApiBaseUrl;
public string ApiBaseUrl => Preferences.Default
.Get(ApiBaseUrlPreferenceKey, GetPlatformDefaultApiBaseUrl())
.TrimEnd('/');
public string HubUrl => $"{DefaultApiBaseUrl.TrimEnd('/')}/hubs/game";
public string HubUrl => $"{ApiBaseUrl}/hubs/game";
private static string GetPlatformDefaultApiBaseUrl()
{
return DeviceInfo.Platform == DevicePlatform.Android
? AndroidEmulatorApiBaseUrl
: DefaultApiBaseUrl;
}
}

View File

@@ -12,6 +12,7 @@ public class GameStateService : IGameStateService
private readonly ISignalRService _signalR;
private readonly ILocalStorageService _localStorage;
private readonly IMessenger _messenger;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private readonly List<QueuedGameAction> _queuedActions = [];
@@ -53,8 +54,9 @@ public class GameStateService : IGameStateService
_signalR.ConnectionStateChanged += async (_, connected) =>
{
_messenger.Send(new ConnectionStateChangedMessage(connected));
if (connected)
{
if (!connected)
return;
try
{
await ResyncAsync();
@@ -63,7 +65,6 @@ public class GameStateService : IGameStateService
{
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
}
}
};
}
@@ -81,14 +82,32 @@ public class GameStateService : IGameStateService
if (CurrentPlayerHand is not null)
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
PublishSyncState(false, "Bereit", _queuedActions.Count);
}
public async Task ResyncAsync()
{
await _syncLock.WaitAsync();
try
{
PublishSyncState(true, "Syncing...", _queuedActions.Count);
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await FlushQueuedActionsAsync();
await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Synchronisiert", _queuedActions.Count);
}
catch
{
PublishSyncState(false, "Sync fehlgeschlagen", _queuedActions.Count);
throw;
}
finally
{
_syncLock.Release();
}
}
public async Task EnqueueActionAsync(string method, params object[] arguments)
@@ -102,21 +121,22 @@ public class GameStateService : IGameStateService
_queuedActions.Add(action);
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
PublishSyncState(false, "Offline-Aktion gespeichert", _queuedActions.Count);
}
public async Task FlushQueuedActionsAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
await _syncLock.WaitAsync();
try
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Warteschlange synchronisiert", _queuedActions.Count);
}
finally
{
_syncLock.Release();
}
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
public async Task ClearLocalGameDataAsync()
@@ -129,5 +149,36 @@ public class GameStateService : IGameStateService
await _localStorage.ClearGameStateAsync();
await _localStorage.ClearPlayerHandAsync();
await _localStorage.ClearQueuedActionsAsync();
PublishSyncState(false, "Bereit", 0);
}
private async Task FlushQueuedActionsCoreAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
{
try
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break;
}
}
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
private void PublishSyncState(bool isSyncing, string statusText, int pendingActions)
{
_messenger.Send(new SyncStateChangedMessage(new SyncStateInfo(isSyncing, statusText, pendingActions)));
}
}

View File

@@ -6,5 +6,6 @@ public interface IApiService
{
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task LogoutAsync();
}

View File

@@ -38,6 +38,8 @@ public class SignalRService : ISignalRService
if (_hubConnection is null)
return false;
ConnectionStateChanged?.Invoke(this, false);
try
{
await _hubConnection.StartAsync(cancellationToken);
@@ -46,6 +48,7 @@ public class SignalRService : ISignalRService
}
catch (Exception ex)
{
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
}
@@ -56,7 +59,9 @@ public class SignalRService : ISignalRService
if (_hubConnection is null)
return;
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false);
}

View File

@@ -124,4 +124,12 @@
</MauiXaml>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Debug|net10.0-android'">
<AndroidManifest>Platforms\Android\AndroidManifest.Debug.xml</AndroidManifest>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Release|net10.0-android'">
<AndroidManifest>Platforms\Android\AndroidManifest.Release.xml</AndroidManifest>
</PropertyGroup>
</Project>

View File

@@ -12,7 +12,9 @@ public partial class GameBoardViewModel : BaseViewModel,
IRecipient<GameStateChangedMessage>,
IRecipient<PlayerHandChangedMessage>,
IRecipient<ChallengeReceivedMessage>,
IRecipient<ErrorOccurredMessage>
IRecipient<ErrorOccurredMessage>,
IRecipient<SyncStateChangedMessage>,
IRecipient<ConnectionStateChangedMessage>
{
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
@@ -33,6 +35,18 @@ public partial class GameBoardViewModel : BaseViewModel,
[ObservableProperty]
private SlipChallengeDto? pendingChallenge;
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
@@ -42,6 +56,7 @@ public partial class GameBoardViewModel : BaseViewModel,
_gameStateService = gameStateService;
_authSession = authSession;
IsConnected = _signalR.IsConnected;
IsActive = true;
}
@@ -164,6 +179,18 @@ public partial class GameBoardViewModel : BaseViewModel,
StatusMessage = message.Value;
}
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
}
private int GetCurrentPlayerId()
{
var username = _authSession.CurrentUser?.Username;

View File

@@ -13,7 +13,8 @@ public partial class LobbyViewModel : BaseViewModel,
IRecipient<LobbyCreatedMessage>,
IRecipient<GameStartedMessage>,
IRecipient<ErrorOccurredMessage>,
IRecipient<ConnectionStateChangedMessage>
IRecipient<ConnectionStateChangedMessage>,
IRecipient<SyncStateChangedMessage>
{
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
@@ -32,6 +33,15 @@ public partial class LobbyViewModel : BaseViewModel,
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerInfoDto> Players { get; } = [];
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
@@ -139,6 +149,13 @@ public partial class LobbyViewModel : BaseViewModel,
IsConnected = message.Value;
}
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
}
private int GetCurrentPlayerId()
{
var username = _authSession.CurrentUser?.Username;

View File

@@ -42,7 +42,11 @@ public partial class LoginViewModel : BaseViewModel
});
await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token);
var connected = await _signalR.ConnectAsync(response.Token);
if (!connected)
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
await _gameStateService.InitializeAsync();
await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Anmeldung läuft...");
@@ -51,11 +55,24 @@ public partial class LoginViewModel : BaseViewModel
[RelayCommand]
public async Task InitializeAsync()
{
bool isAuthenticated = _authSession.IsAuthenticated;
if (!_authSession.IsAuthenticated)
return;
if (isAuthenticated)
var isSessionValid = await _apiService.ValidateSessionAsync();
if (!isSessionValid)
{
await _authSession.ClearSessionAsync();
await _gameStateService.ClearLocalGameDataAsync();
await _signalR.DisconnectAsync();
return;
}
var token = _authSession.AccessToken;
if (string.IsNullOrWhiteSpace(token))
return;
var connected = await _signalR.ConnectAsync(token);
if (connected)
await Shell.Current.GoToAsync(nameof(LobbyPage));
}
}
}

View File

@@ -10,6 +10,9 @@
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
<Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" TextColor="Gray" />
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
<Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" />
@@ -17,7 +20,7 @@
<CollectionView ItemsSource="{Binding MyCards}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Margin="0,4" Padding="10">
<Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White">
<VerticalStackLayout>
<Label Text="{Binding Text}" />
<HorizontalStackLayout>
@@ -25,7 +28,7 @@
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

View File

@@ -7,6 +7,8 @@
<VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" />
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
<Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />