mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: add JSON tags to Role/VoiceState, fix WebSocket error surfacing
Root cause: server's db.Role and db.VoiceState structs had no JSON tags, causing Go to serialize field names as PascalCase while the C# client expected snake_case. Every role deserialized with Id=0, crashing ToDictionary with "duplicate key: 0". - Add json tags to Role and VoiceState in Server/db/models.go - Change Disconnected event to carry reason string for diagnostics - Wire ErrorReceived in MainViewModel to show server-side WS errors - Fix MainWindow to surface WebSocket errors on MainPage (not ConnectPage) - Use _reconnectCts.Token for receive loop instead of caller's token - Make ToDictionary calls safe with TryAdd to prevent future crashes
This commit is contained in:
@@ -62,7 +62,7 @@ public class FakeWebSocketService : IWebSocketService
|
||||
public WebSocketState State { get; set; } = WebSocketState.None;
|
||||
|
||||
public event Action<string>? MessageReceived;
|
||||
public event Action? Disconnected;
|
||||
public event Action<string>? Disconnected;
|
||||
|
||||
public string? LastConnectUri { get; private set; }
|
||||
public string? LastConnectToken { get; private set; }
|
||||
@@ -104,11 +104,11 @@ public class FakeWebSocketService : IWebSocketService
|
||||
|
||||
// Test helpers to simulate server messages
|
||||
public void SimulateMessage(string json) => MessageReceived?.Invoke(json);
|
||||
public void SimulateDisconnect()
|
||||
public void SimulateDisconnect(string reason = "test disconnect")
|
||||
{
|
||||
IsConnected = false;
|
||||
State = WebSocketState.Closed;
|
||||
Disconnected?.Invoke();
|
||||
Disconnected?.Invoke(reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,15 @@ public partial class MainWindow : Window
|
||||
_mainVm.Initialize(_chat);
|
||||
RootFrame.Navigate(new MainPage(_mainVm));
|
||||
|
||||
await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!);
|
||||
try
|
||||
{
|
||||
await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!);
|
||||
}
|
||||
catch (Exception wsEx)
|
||||
{
|
||||
// WebSocket errors after navigation should show on MainPage, not ConnectPage
|
||||
_mainVm.ConnectionStatus = $"WebSocket failed: {wsEx.Message}";
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
@@ -83,7 +91,14 @@ public partial class MainWindow : Window
|
||||
_mainVm.Initialize(_chat);
|
||||
RootFrame.Navigate(new MainPage(_mainVm));
|
||||
|
||||
await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!);
|
||||
try
|
||||
{
|
||||
await _chat.ConnectWebSocketAsync(host, _chat.CurrentToken!);
|
||||
}
|
||||
catch (Exception wsEx)
|
||||
{
|
||||
_mainVm.ConnectionStatus = $"WebSocket failed: {wsEx.Message}";
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class ChatService : IChatService
|
||||
_ws = ws;
|
||||
|
||||
_ws.MessageReceived += OnMessageReceived;
|
||||
_ws.Disconnected += OnDisconnected;
|
||||
_ws.Disconnected += reason => OnDisconnected(reason);
|
||||
}
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────────
|
||||
@@ -105,7 +105,10 @@ public sealed class ChatService : IChatService
|
||||
|
||||
var wsUri = $"wss://{ApiClient.NormalizeHost(host)}/api/v1/ws";
|
||||
await _ws.ConnectAsync(wsUri, token, ct);
|
||||
_ = RunReceiveLoopWithErrorHandlingAsync(ct);
|
||||
|
||||
// Use the reconnect CTS so the loop can be cancelled on logout/disconnect,
|
||||
// not the caller's token which may be default/already disposed.
|
||||
_ = RunReceiveLoopWithErrorHandlingAsync(_reconnectCts.Token);
|
||||
}
|
||||
|
||||
private async Task RunReceiveLoopWithErrorHandlingAsync(CancellationToken ct)
|
||||
@@ -321,9 +324,9 @@ public sealed class ChatService : IChatService
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisconnected()
|
||||
private void OnDisconnected(string reason)
|
||||
{
|
||||
ConnectionLost?.Invoke("WebSocket connection lost");
|
||||
ConnectionLost?.Invoke(reason);
|
||||
|
||||
if (!_intentionalDisconnect && _host is not null && CurrentToken is not null)
|
||||
_ = ReconnectAsync();
|
||||
|
||||
@@ -10,8 +10,8 @@ public interface IWebSocketService
|
||||
/// <summary>Fires for each raw JSON message received.</summary>
|
||||
event Action<string>? MessageReceived;
|
||||
|
||||
/// <summary>Fires when the connection drops unexpectedly.</summary>
|
||||
event Action? Disconnected;
|
||||
/// <summary>Fires when the connection drops unexpectedly, with a reason string.</summary>
|
||||
event Action<string>? Disconnected;
|
||||
|
||||
Task ConnectAsync(string uri, string token, CancellationToken ct = default);
|
||||
Task SendAsync(object message, CancellationToken ct = default);
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class WebSocketService : IWebSocketService, IDisposable
|
||||
public WebSocketState State => _ws?.State ?? WebSocketState.None;
|
||||
|
||||
public event Action<string>? MessageReceived;
|
||||
public event Action? Disconnected;
|
||||
public event Action<string>? Disconnected;
|
||||
|
||||
public async Task ConnectAsync(string uri, string token, CancellationToken ct = default)
|
||||
{
|
||||
@@ -67,7 +67,8 @@ public sealed class WebSocketService : IWebSocketService, IDisposable
|
||||
result = await _ws.ReceiveAsync(buf, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
Disconnected?.Invoke();
|
||||
var desc = _ws.CloseStatusDescription ?? _ws.CloseStatus?.ToString() ?? "server closed connection";
|
||||
Disconnected?.Invoke(desc);
|
||||
return;
|
||||
}
|
||||
ms.Write(buf, 0, result.Count);
|
||||
@@ -81,9 +82,9 @@ public sealed class WebSocketService : IWebSocketService, IDisposable
|
||||
{
|
||||
// Normal shutdown via cancellation.
|
||||
}
|
||||
catch (WebSocketException)
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
Disconnected?.Invoke();
|
||||
Disconnected?.Invoke($"WebSocket error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ public sealed class MainViewModel : ViewModelBase, IDisposable
|
||||
chat.ChannelUpdated += p => RunOnUI(() => OnChannelUpdated(p));
|
||||
chat.ChannelDeleted += id => RunOnUI(() => OnChannelDeleted(id));
|
||||
chat.ConnectionLost += r => RunOnUI(() => OnConnectionLost(r));
|
||||
chat.ErrorReceived += p => RunOnUI(() => OnWsError(p));
|
||||
chat.VoiceStateReceived += p => RunOnUI(() => OnVoiceState(p));
|
||||
chat.VoiceLeaveReceived += p => RunOnUI(() => OnVoiceLeave(p));
|
||||
chat.VoiceSpeakersReceived += p => RunOnUI(() => OnVoiceSpeakers(p));
|
||||
@@ -675,7 +676,9 @@ public sealed class MainViewModel : ViewModelBase, IDisposable
|
||||
private void RebuildChannelGroups()
|
||||
{
|
||||
// Preserve expanded state across rebuilds
|
||||
var expandedState = ChannelGroups.ToDictionary(g => g.CategoryName ?? "", g => g.IsExpanded);
|
||||
var expandedState = new Dictionary<string, bool>();
|
||||
foreach (var g in ChannelGroups)
|
||||
expandedState.TryAdd(g.CategoryName ?? "", g.IsExpanded);
|
||||
|
||||
ChannelGroups.Clear();
|
||||
|
||||
@@ -715,7 +718,9 @@ public sealed class MainViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
MemberGroups.Clear();
|
||||
|
||||
var roleMap = Roles.ToDictionary(r => r.Id);
|
||||
var roleMap = new Dictionary<long, WsRole>();
|
||||
foreach (var r in Roles)
|
||||
roleMap.TryAdd(r.Id, r);
|
||||
|
||||
var grouped = Members
|
||||
.GroupBy(m => m.RoleId)
|
||||
@@ -994,7 +999,12 @@ public sealed class MainViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void OnConnectionLost(string reason)
|
||||
{
|
||||
ConnectionStatus = "Disconnected \u2014 reconnecting...";
|
||||
ConnectionStatus = $"Disconnected \u2014 {reason}";
|
||||
}
|
||||
|
||||
private void OnWsError(WsErrorPayload error)
|
||||
{
|
||||
ConnectionStatus = $"Server error: {error.Message}";
|
||||
}
|
||||
|
||||
// ── Voice event handlers ─────────────────────────────────────────────────
|
||||
|
||||
+14
-14
@@ -44,12 +44,12 @@ type Invite struct {
|
||||
|
||||
// Role represents a row in the roles table.
|
||||
type Role struct {
|
||||
ID int64
|
||||
Name string
|
||||
Color *string
|
||||
Permissions int64
|
||||
Position int
|
||||
IsDefault bool
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Permissions int64 `json:"permissions"`
|
||||
Position int `json:"position"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// Channel represents a row in the channels table.
|
||||
@@ -109,14 +109,14 @@ type MessageSearchResult struct {
|
||||
// VoiceState represents a row in the voice_states table.
|
||||
// It tracks which voice channel a user is in and their current audio state.
|
||||
type VoiceState struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
Username string
|
||||
Muted bool
|
||||
Deafened bool
|
||||
Speaking bool
|
||||
Camera bool
|
||||
Screenshare bool
|
||||
UserID int64 `json:"user_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Username string `json:"username"`
|
||||
Muted bool `json:"muted"`
|
||||
Deafened bool `json:"deafened"`
|
||||
Speaking bool `json:"speaking"`
|
||||
Camera bool `json:"camera"`
|
||||
Screenshare bool `json:"screenshare"`
|
||||
}
|
||||
|
||||
// ServerStats contains aggregate counts for the admin dashboard.
|
||||
|
||||
Reference in New Issue
Block a user