mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
## Summary - add foreground mobile Huddles on Android and iOS with native Opus capture/playback, mute, speaker routing, participants, lifecycle, and minimized drawer UI - keep mobile Huddle cards and roster state live, including ended rooms, relay-resolved profiles, and agents - broadcast desktop agent TTS through the existing Huddle audio protocol ## Scope Foreground human-to-human voice MVP only. Agent setup/transcripts, background calling, recording, and advanced device controls remain out of scope. ## Validation - `just mobile-check` - `just mobile-test` — 1,500 passed - `just desktop-check` and `just desktop-test` — 4,957 passed - desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15 ignored - mobile worktree identity contract checks - physical Pixel/iPhone behavior reviewed during development --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
125 lines
3.6 KiB
Dart
125 lines
3.6 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../relay/nostr_models.dart';
|
|
import 'huddle_wire.dart';
|
|
|
|
/// Immutable connection inputs for one Huddle audio WebSocket.
|
|
@immutable
|
|
final class HuddleConnectionParameters {
|
|
final String relayWebSocketUrl;
|
|
final String nsec;
|
|
final String parentChannelId;
|
|
final String ephemeralChannelId;
|
|
|
|
HuddleConnectionParameters({
|
|
required this.relayWebSocketUrl,
|
|
required this.nsec,
|
|
required this.parentChannelId,
|
|
required this.ephemeralChannelId,
|
|
}) {
|
|
final relayUri = Uri.tryParse(relayWebSocketUrl);
|
|
if (relayUri == null ||
|
|
!relayUri.hasAuthority ||
|
|
(relayUri.scheme != 'ws' && relayUri.scheme != 'wss')) {
|
|
throw ArgumentError.value(
|
|
relayWebSocketUrl,
|
|
'relayWebSocketUrl',
|
|
'must be an absolute ws:// or wss:// relay URL',
|
|
);
|
|
}
|
|
if (relayUri.hasQuery || relayUri.hasFragment) {
|
|
throw ArgumentError.value(
|
|
relayWebSocketUrl,
|
|
'relayWebSocketUrl',
|
|
'must not contain a query or fragment',
|
|
);
|
|
}
|
|
_validateUuid(parentChannelId, 'parentChannelId');
|
|
_validateUuid(ephemeralChannelId, 'ephemeralChannelId');
|
|
if (nsec.trim().isEmpty) {
|
|
throw ArgumentError.value(nsec, 'nsec', 'must not be empty');
|
|
}
|
|
}
|
|
|
|
/// The endpoint is derived from the relay origin, but NIP-42's `relay` tag
|
|
/// must remain [relayWebSocketUrl], not this Huddle-specific URL.
|
|
Uri get audioWebSocketUri {
|
|
final base = Uri.parse(relayWebSocketUrl);
|
|
return base.replace(
|
|
path: '/huddle/${Uri.encodeComponent(ephemeralChannelId)}/audio',
|
|
query: null,
|
|
fragment: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fixed NIP-42 + Huddle auth envelope used after the audio relay challenge.
|
|
abstract final class HuddleAuthV3 {
|
|
static Map<String, dynamic> buildMessage({
|
|
required HuddleConnectionParameters parameters,
|
|
required String challenge,
|
|
int? createdAt,
|
|
}) {
|
|
if (challenge.isEmpty) {
|
|
throw const HuddleAuthException('Relay challenge must not be empty.');
|
|
}
|
|
|
|
final secretKey = _decodeSecretKey(parameters.nsec);
|
|
final event = nostr.Event.from(
|
|
kind: EventKind.auth,
|
|
content: '',
|
|
tags: [
|
|
['relay', parameters.relayWebSocketUrl],
|
|
['challenge', challenge],
|
|
],
|
|
secretKey: secretKey,
|
|
createdAt: createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
verify: false,
|
|
);
|
|
|
|
return {
|
|
'type': 'auth',
|
|
'event': event.toMap(),
|
|
'parent_channel_id': parameters.parentChannelId,
|
|
'protocol_version': HuddleWireV3.protocolVersion,
|
|
};
|
|
}
|
|
|
|
static String _decodeSecretKey(String value) {
|
|
final trimmed = value.trim();
|
|
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
try {
|
|
final decoded = nostr.Nip19.decode(payload: trimmed).data;
|
|
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(decoded)) {
|
|
return decoded.toLowerCase();
|
|
}
|
|
} catch (_) {
|
|
// Fall through to the stable domain error below.
|
|
}
|
|
throw const HuddleAuthException('Invalid Nostr signing key.');
|
|
}
|
|
}
|
|
|
|
final class HuddleAuthException implements Exception {
|
|
final String message;
|
|
|
|
const HuddleAuthException(this.message);
|
|
|
|
@override
|
|
String toString() => 'HuddleAuthException: $message';
|
|
}
|
|
|
|
final _canonicalUuid = RegExp(
|
|
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-'
|
|
r'[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
|
);
|
|
|
|
void _validateUuid(String value, String name) {
|
|
if (!_canonicalUuid.hasMatch(value)) {
|
|
throw ArgumentError.value(value, name, 'must be a canonical UUID');
|
|
}
|
|
}
|