Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions clients/macos/Aura/Services/QuicNetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,18 @@ public class QuicNetworkClient {
case Self.MSG_CHANNEL_DELETED: // 0x47 - The channel we were in was deleted
await handleChannelDeleted(stream: stream)

case Self.MSG_CREATE_CHANNEL: // 0x40 - Reply to our CreateChannelRequest
await handleCreateChannelResponse(stream: stream)

case Self.MSG_UPDATE_CHANNEL: // 0x41 - Reply to our UpdateChannelRequest
await handleUpdateChannelResponse(stream: stream)

case Self.MSG_UPDATE_PROFILE: // 0x42 - Reply to our UpdateProfile request
await handleUpdateProfileResponse(stream: stream)

case Self.MSG_DELETE_CHANNEL: // 0x43 - Reply to our DeleteChannelRequest
await handleDeleteChannelResponse(stream: stream)

default:
print(String(format: "[QuicClient] Unknown message type: 0x%02X", type))
break
Expand Down Expand Up @@ -1436,6 +1448,96 @@ public class QuicNetworkClient {
}
}

/// Handle the server's reply to our CreateChannelRequest. On success the
/// new channel arrives via the ServerState broadcast that follows, so we
/// only need to surface failures here.
private func handleCreateChannelResponse(stream: NWConnection) async {
do {
let payload = try await receiveHardenedPayload(maxLen: Self.MAX_CONTROL_PACKET_SIZE, on: stream)
let response = try decodeCreateChannelResponse(data: payload)
if !response.success {
print("[QuicClient] Create channel failed: \(response.errorMessage)")
await MainActor.run {
self.demoteIfAdminRequired(response.errorMessage)
self.systemEvents.append(
SystemEvent(content: "Failed to create channel: \(response.errorMessage)"))
}
}
} catch {
print("[QuicClient] Failed to parse CreateChannelResponse: \(error)")
}
}

/// Handle the server's reply to our UpdateChannelRequest.
private func handleUpdateChannelResponse(stream: NWConnection) async {
do {
let payload = try await receiveHardenedPayload(maxLen: Self.MAX_CONTROL_PACKET_SIZE, on: stream)
let response = try decodeMetadataUpdateResponse(data: payload)
if !response.success {
print("[QuicClient] Update channel failed: \(response.errorMessage)")
await MainActor.run {
self.demoteIfAdminRequired(response.errorMessage)
self.systemEvents.append(
SystemEvent(content: "Failed to update channel: \(response.errorMessage)"))
}
}
} catch {
print("[QuicClient] Failed to parse UpdateChannel MetadataUpdateResponse: \(error)")
}
}

/// Handle the server's reply to our UpdateProfile request. Not
/// admin-gated, so no `isAdmin` bookkeeping here.
private func handleUpdateProfileResponse(stream: NWConnection) async {
do {
let payload = try await receiveHardenedPayload(maxLen: Self.MAX_CONTROL_PACKET_SIZE, on: stream)
let response = try decodeMetadataUpdateResponse(data: payload)
if !response.success {
print("[QuicClient] Update profile failed: \(response.errorMessage)")
await MainActor.run {
self.systemEvents.append(
SystemEvent(content: "Failed to update profile: \(response.errorMessage)"))
}
}
} catch {
print("[QuicClient] Failed to parse UpdateProfile MetadataUpdateResponse: \(error)")
}
}

/// Handle the server's reply to our DeleteChannelRequest.
private func handleDeleteChannelResponse(stream: NWConnection) async {
do {
let payload = try await receiveHardenedPayload(maxLen: Self.MAX_CONTROL_PACKET_SIZE, on: stream)
let response = try decodeAdminResponse(data: payload)
if !response.success {
print("[QuicClient] Delete channel failed: \(response.errorMessage)")
await MainActor.run {
self.demoteIfAdminRequired(response.errorMessage)
self.systemEvents.append(
SystemEvent(content: "Failed to delete channel: \(response.errorMessage)"))
}
}
} catch {
print("[QuicClient] Failed to parse DeleteChannel AdminResponse: \(error)")
}
}

/// The server re-checks admin status on every request rather than
/// trusting a cached flag, but our own `isAdmin` is only set once at
/// login (see AuthResponse handling above) and never refreshed. If a
/// request comes back rejected specifically because admin rights are
/// gone, drop the local flag immediately instead of waiting for a
/// reconnect to notice the mismatch — this hides the create/edit/delete
/// UI right away rather than letting the user hit the same rejection
/// repeatedly.
@MainActor
private func demoteIfAdminRequired(_ errorMessage: String) {
if isAdmin && errorMessage == "Admin required" {
print("[QuicClient] Server rejected admin action; clearing stale isAdmin flag")
isAdmin = false
}
}

/// Handle ServerState snapshot (Protobuf via UniFFI)
private func handleServerState(stream: NWConnection) async {
do {
Expand Down
60 changes: 60 additions & 0 deletions crates/aura-core/src/uniffi_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,28 @@ pub struct ChannelDeletedRecord {
pub fallback_channel_id: String,
}

/// Server's reply to a `CreateChannelRequest` (MSG_CREATE_CHANNEL, 0x40).
#[derive(Debug, Clone, uniffi::Record)]
pub struct CreateChannelResponseRecord {
pub success: bool,
pub channel_id: String,
pub error_message: String,
}

/// Server's reply to `UpdateChannelRequest` (0x41) or `UpdateProfile` (0x42).
#[derive(Debug, Clone, uniffi::Record)]
pub struct MetadataUpdateResponseRecord {
pub success: bool,
pub error_message: String,
}

/// Server's reply to `DeleteChannelRequest` (0x43) or `DeleteUserRequest` (0x44).
#[derive(Debug, Clone, uniffi::Record)]
pub struct AdminResponseRecord {
pub success: bool,
pub error_message: String,
}

#[derive(Debug, Clone, uniffi::Enum)]
pub enum MlsGroupType {
Voice,
Expand Down Expand Up @@ -845,6 +867,44 @@ pub fn decode_channel_deleted(data: Vec<u8>) -> Result<ChannelDeletedRecord, Aud
})
}

#[uniffi::export]
pub fn decode_create_channel_response(
data: Vec<u8>,
) -> Result<CreateChannelResponseRecord, AudioError> {
use prost::Message;
let proto = aura_protocol::CreateChannelResponse::decode(&data[..])
.map_err(|_| AudioError::PacketParseError)?;
Ok(CreateChannelResponseRecord {
success: proto.success,
channel_id: proto.channel_id,
error_message: proto.error_message,
})
}

#[uniffi::export]
pub fn decode_metadata_update_response(
data: Vec<u8>,
) -> Result<MetadataUpdateResponseRecord, AudioError> {
use prost::Message;
let proto = aura_protocol::MetadataUpdateResponse::decode(&data[..])
.map_err(|_| AudioError::PacketParseError)?;
Ok(MetadataUpdateResponseRecord {
success: proto.success,
error_message: proto.error_message,
})
}

#[uniffi::export]
pub fn decode_admin_response(data: Vec<u8>) -> Result<AdminResponseRecord, AudioError> {
use prost::Message;
let proto = aura_protocol::AdminResponse::decode(&data[..])
.map_err(|_| AudioError::PacketParseError)?;
Ok(AdminResponseRecord {
success: proto.success,
error_message: proto.error_message,
})
}

#[uniffi::export]
pub fn encode_join_channel_request(req: JoinChannelRequestRecord) -> Vec<u8> {
use prost::Message;
Expand Down
17 changes: 15 additions & 2 deletions crates/aura-server/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,15 @@ impl ServerState {
});
}

// DashMap iteration order is unspecified; sort so clients see a
// stable, position-respecting order (mirrors the DB's
// `ORDER BY position, channel_id` in `Database::get_all_channels`).
channels.sort_by(|a, b| {
a.position
.cmp(&b.position)
.then_with(|| a.channel_id.cmp(&b.channel_id))
});

let profiles: Vec<UserProfile> = self.profiles.iter().map(|p| p.value().clone()).collect();

info!(
Expand Down Expand Up @@ -1299,14 +1308,18 @@ impl ServerState {
) -> Result<String> {
let (icon_type, icon_data) = self.convert_proto_icon(icon);
let channel_type = 0; // Default to Regular for manually created channels
// Append after existing channels instead of colliding at 0 — new
// channels previously all landed at position 0, leaving their
// relative order to DashMap iteration (see get_server_snapshot).
let position = self.channel_metadata.len() as i32;

let channel_id = self.db.upsert_channel(
None,
&name,
&comment,
icon_type,
&icon_data,
0,
position,
channel_type,
)?;

Expand All @@ -1319,7 +1332,7 @@ impl ServerState {
comment,
icon_type,
icon_data,
position: 0,
position,
channel_type,
},
);
Expand Down
Loading