From e77513181bee2bebaaaa2decdaa804e72258ae7c Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:21:35 +0900 Subject: [PATCH 1/6] [fix] property name --- .../SlackChatInterface.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Clawleash.Interfaces.Slack/SlackChatInterface.cs b/Clawleash.Interfaces.Slack/SlackChatInterface.cs index cdc61d3..a46397e 100644 --- a/Clawleash.Interfaces.Slack/SlackChatInterface.cs +++ b/Clawleash.Interfaces.Slack/SlackChatInterface.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Clawleash.Abstractions.Services; using Microsoft.Extensions.Logging; @@ -538,78 +539,127 @@ private class ChannelInfo internal class AuthTestResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + + [JsonPropertyName("user_id")] public string? UserId { get; set; } + + [JsonPropertyName("user")] public string? User { get; set; } + + [JsonPropertyName("team")] public string? Team { get; set; } + + [JsonPropertyName("error")] public string? Error { get; set; } } internal class ChannelInfoResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + public SlackChannelInfo? Channel { get; set; } } internal class SlackChannelInfo { + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + + [JsonPropertyName("is_private")] public bool IsPrivate { get; set; } } internal class ConversationsHistoryResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + public List? Messages { get; set; } } internal class SlackMessage { + [JsonPropertyName("ts")] public string Ts { get; set; } = string.Empty; + + [JsonPropertyName("user")] public string User { get; set; } = string.Empty; + + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; + + [JsonPropertyName("thread_ts")] public string? ThreadTs { get; set; } + + [JsonPropertyName("subtype")] public string? Subtype { get; set; } + + [JsonPropertyName("bot_id")] public string? BotId { get; set; } } internal class UserInfoResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + public SlackUser? User { get; set; } } internal class SlackUser { + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + public SlackUserProfile? Profile { get; set; } } internal class SlackUserProfile { + [JsonPropertyName("display_name")] public string? DisplayName { get; set; } + + [JsonPropertyName("real_name")] public string? RealName { get; set; } } internal class PostMessageResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + + [JsonPropertyName("ts")] public string? Ts { get; set; } + + [JsonPropertyName("error")] public string? Error { get; set; } } internal class ImOpenResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + public SlackChannelInfo? Channel { get; set; } + + [JsonPropertyName("error")] public string? Error { get; set; } } internal class ChannelsListResponse { + [JsonPropertyName("ok")] public bool Ok { get; set; } + public List? Channels { get; set; } } From a04bd52133d365ddc700be8665df2aa43fd28bbb Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:28:06 +0900 Subject: [PATCH 2/6] [fix] rtc --- .../Clawleash.Interfaces.WebRTC.csproj | 31 ++ Clawleash.Interfaces.WebRTC/README.md | 191 ++++++++ .../WebRtcChatInterface.cs | 460 +++++++++++++----- Clawleash.Interfaces.WebRTC/WebRtcSettings.cs | 31 ++ 4 files changed, 592 insertions(+), 121 deletions(-) create mode 100644 Clawleash.Interfaces.WebRTC/README.md diff --git a/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj b/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj index 463362e..8a27a80 100644 --- a/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj +++ b/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj @@ -1,10 +1,34 @@ + net10.0 enable enable Clawleash.Interfaces.WebRTC + true + + + Clawleash.Interfaces.WebRTC + 0.1.0 + Clawleash + WebRTC chat interface for Clawleash with native P2P communication support + webrtc;p2p;chat;realtime;signalr;datachannel + README.md + MIT + false + https://github.com/yourname/clowleash + git + + + false + true + snupkg + true + true + + + true @@ -12,7 +36,14 @@ + + + + + + + diff --git a/Clawleash.Interfaces.WebRTC/README.md b/Clawleash.Interfaces.WebRTC/README.md new file mode 100644 index 0000000..a2fee14 --- /dev/null +++ b/Clawleash.Interfaces.WebRTC/README.md @@ -0,0 +1,191 @@ +# Clawleash.Interfaces.WebRTC + +WebRTC チャットインターフェースの完全実装。SignalR シグナリングサーバー経由で WebRTC P2P 接続を確立し、DataChannel でリアルタイム通信を行います。 + +## 機能 + +- **ネイティブ WebRTC**: Rust (webrtc-rs) ベースのネイティブライブラリによる高速な P2P 通信 +- **フォールバック対応**: ネイティブライブラリがない場合はシミュレーションモードで動作 +- **E2EE 対応**: DataChannel 通信のエンドツーエンド暗号化 +- **STUN/TURN**: NAT 超え対応(STUN/TURN サーバー設定可能) +- **自動再接続**: SignalR 自動再接続とピア接続の自動復旧 + +## アーキテクチャ + +``` +┌──────────────────────────────────────────────────┐ +│ WebRtcChatInterface (C#) │ +│ ┌────────────────────────────────────────────┐ │ +│ │ WebRtcNativeClient (P/Invoke wrapper) │ │ +│ │ - Event polling thread │ │ +│ │ - Message serialization │ │ +│ └────────────────────────────────────────────┘ │ +│ │ P/Invoke │ +│ ▼ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ webrtc_client.dll (Rust cdylib) │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ + │ + ┌───────────────────────┐ + │ SignalR Server │ + │ (Signaling) │ + └───────────────────────┘ +``` + +## ネイティブライブラリのビルド + +### 前提条件 + +- Rust 1.70 以降 +- Cargo + +### ビルド手順 + +```bash +# webrtc-client リポジトリに移動 +cd ../webrtc-client + +# Release ビルド +cargo build --release -p webrtc-client-sys + +# 出力ファイルを Clowleash にコピー +# Windows x64: +copy target\release\webrtc_client.dll ..\Clowleash\Clawleash.Interfaces.WebRTC\Native\win-x64\ + +# Linux x64: +cp target/release/libwebrtc_client.so ../Clowleash/Clawleash.Interfaces.WebRTC/Native/linux-x64/ + +# macOS x64: +cp target/release/libwebrtc_client.dylib ../Clowleash/Clawleash.Interfaces.WebRTC/Native/osx-x64/ +``` + +## 使用方法 + +### 設定 + +```csharp +var settings = new WebRtcSettings +{ + SignalingServerUrl = "http://localhost:8080/signaling", + StunServers = new List + { + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302" + }, + TurnServerUrl = "turn:turn.example.com:3478", // オプション + TurnUsername = "user", + TurnPassword = "password", + EnableE2ee = true, + IceConnectionTimeoutMs = 30000, + MaxReconnectAttempts = 5 +}; +``` + +### 基本的な使用 + +```csharp +var chatInterface = new WebRtcChatInterface(settings, logger); + +// イベントハンドラー +chatInterface.MessageReceived += (sender, args) => +{ + Console.WriteLine($"Message from {args.SenderName}: {args.Content}"); +}; + +// 開始 +await chatInterface.StartAsync(cancellationToken); + +// メッセージ送信 +await chatInterface.SendMessageAsync("Hello, World!"); + +// 終了 +await chatInterface.DisposeAsync(); +``` + +## 設定オプション + +| プロパティ | 説明 | デフォルト | +|-----------|------|-----------| +| `SignalingServerUrl` | SignalR シグナリングサーバー URL | `ws://localhost:8080/signaling` | +| `StunServers` | STUN サーバー URL リスト | Google STUN サーバー | +| `TurnServerUrl` | TURN サーバー URL | `null` | +| `TurnUsername` | TURN ユーザー名 | `null` | +| `TurnPassword` | TURN パスワード | `null` | +| `EnableE2ee` | E2EE 有効化 | `false` | +| `IceConnectionTimeoutMs` | ICE 接続タイムアウト | `30000` | +| `MaxReconnectAttempts` | 最大再接続試行回数 | `5` | +| `ReconnectIntervalMs` | 再接続間隔 | `5000` | +| `TryUseNativeClient` | ネイティブクライアント使用試行 | `true` | +| `DataChannelReliable` | DataChannel 信頼性モード | `true` | +| `HeartbeatIntervalMs` | ハートビート間隔 | `30000` | + +## イベント + +### MessageReceived + +メッセージ受信時に発生するイベント。 + +```csharp +chatInterface.MessageReceived += (sender, args) => +{ + // args.MessageId - メッセージ ID + // args.SenderId - 送信者ピア ID + // args.SenderName - 送信者名 + // args.Content - メッセージ内容 + // args.Timestamp - タイムスタンプ + // args.Metadata["native"] - ネイティブクライアント使用フラグ + // args.Metadata["encrypted"] - 暗号化フラグ +}; +``` + +## シミュレーションモード + +ネイティブライブラリが利用できない場合、インターフェースはシミュレーションモードで動作します。 + +- SignalR 経由でメッセージを中継 +- 実際の P2P 通信は行わない +- 開発・テスト環境で使用可能 + +## トラブルシューティング + +### "Native WebRTC library not found" + +ネイティブライブラリが見つからない場合: +1. `webrtc-client` Rust プロジェクトをビルド +2. 適切なプラットフォームフォルダーに DLL/SO/DYLIB をコピー +3. または `TryUseNativeClient = false` でシミュレーションモードを使用 + +### "Architecture mismatch" + +アプリケーションとライブラリのアーキテクチャが一致しない場合: +- x64 アプリケーション → x64 ライブラリ +- arm64 アプリケーション → arm64 ライブラリ + +### ICE 接続タイムアウト + +NAT 超えできない場合: +- STUN サーバーが正しく設定されているか確認 +- TURN サーバーの使用を検討 +- `IceConnectionTimeoutMs` を増加 + +## テスト + +```bash +# 単体テスト +dotnet test Clawleash.Interfaces.WebRTC.Tests + +# 統合テスト +# Terminal 1: Signaling Server +cd Clawleash.Server && dotnet run + +# Terminal 2: Client 1 +cd Clawleash && dotnet run + +# Terminal 3: Client 2 +cd Clawleash && dotnet run +``` + +## ライセンス + +MIT diff --git a/Clawleash.Interfaces.WebRTC/WebRtcChatInterface.cs b/Clawleash.Interfaces.WebRTC/WebRtcChatInterface.cs index 6082328..09a8c05 100644 --- a/Clawleash.Interfaces.WebRTC/WebRtcChatInterface.cs +++ b/Clawleash.Interfaces.WebRTC/WebRtcChatInterface.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using System.Text; -using System.Text.Json; using Clawleash.Abstractions.Services; using Clawleash.Interfaces.WebRTC.Security; +using Lucid.Rtc; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; @@ -11,7 +11,7 @@ namespace Clawleash.Interfaces.WebRTC; /// /// WebRTCチャットインターフェース /// SignalRシグナリングサーバー経由でWebRTC接続を確立し、DataChannelで通信 -/// 完全実装:ピア発見、SDP交換、ICE候補交換、DataChannel通信 +/// Lucid.Rtc高レベルAPIを使用 /// public class WebRtcChatInterface : IChatInterface { @@ -21,11 +21,14 @@ public class WebRtcChatInterface : IChatInterface private HubConnection? _hubConnection; private CancellationTokenSource? _cts; private readonly ConcurrentDictionary _channelTracking = new(); - private readonly ConcurrentDictionary _peerConnections = new(); private bool _disposed; private bool _isConnected; private string? _localPeerId; + // Lucid.Rtc high-level API + private RtcConnection? _rtcConnection; + private readonly ConcurrentDictionary _peers = new(); + // 接続状態 private int _activeConnections; private readonly object _connectionLock = new(); @@ -35,16 +38,6 @@ public class WebRtcChatInterface : IChatInterface public event EventHandler? MessageReceived; - // WebRTCピア接続状態 - private class PeerConnectionState - { - public string PeerId { get; set; } = string.Empty; - public string ConnectionId { get; set; } = string.Empty; - public bool IsDataChannelReady { get; set; } - public DateTime ConnectedAt { get; set; } - public string? SessionKey { get; set; } - } - public WebRtcChatInterface( WebRtcSettings settings, ILogger? logger = null) @@ -66,6 +59,9 @@ public async Task StartAsync(CancellationToken cancellationToken = default) try { + // Initialize Lucid.Rtc connection + InitializeRtcConnection(); + // SignalRハブ接続を構築 _hubConnection = new HubConnectionBuilder() .WithUrl(_settings.SignalingServerUrl) @@ -109,7 +105,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) // 既存のピアを取得して接続開始 await DiscoverAndConnectPeersAsync(); - _logger?.LogInformation("WebRTC interface started. E2EE: {E2ee}", + _logger?.LogInformation("WebRTC interface started. E2EE: {E2ee}, Backend: Lucid.Rtc", _settings.EnableE2ee ? "Enabled" : "Disabled"); } catch (Exception ex) @@ -119,6 +115,208 @@ public async Task StartAsync(CancellationToken cancellationToken = default) } } + private void InitializeRtcConnection() + { + var builder = new RtcConnectionBuilder(); + + // STUN servers + foreach (var stunServer in _settings.StunServers) + { + builder.WithStunServer(stunServer); + } + + // TURN server (optional) + if (!string.IsNullOrEmpty(_settings.TurnServerUrl)) + { + builder.WithTurnServer( + _settings.TurnServerUrl, + _settings.TurnUsername ?? "", + _settings.TurnPassword ?? ""); + } + + // Other settings + builder + .WithIceConnectionTimeout(_settings.IceConnectionTimeoutMs) + .WithDataChannelReliable(_settings.DataChannelReliable); + + _rtcConnection = builder.Build(); + + // Register event handlers with method chaining + _rtcConnection + .On(e => OnPeerConnected(e.PeerId, e.Peer)) + .On(e => OnPeerDisconnected(e.PeerId)) + .On(e => HandleMessage(e.PeerId, e.Data)) + .On(e => SendIceCandidate(e.PeerId, e.Candidate)) + .On(e => SendOffer(e.PeerId, e.Sdp)) + .On(e => SendAnswer(e.PeerId, e.Sdp)) + .On(e => OnDataChannelOpen(e.PeerId, e.Peer)) + .On(e => OnDataChannelClosed(e.PeerId, e.Peer)) + .On(e => _logger?.LogError("Lucid.Rtc error: {Message}", e.Message)); + + _logger?.LogInformation("Lucid.Rtc connection initialized"); + } + + private void OnPeerConnected(string peerId, Peer peer) + { + _ = Task.Run(async () => + { + try + { + lock (_connectionLock) + { + _activeConnections++; + } + + _peers[peerId] = peer; + + _logger?.LogInformation("Peer connected: {PeerId}. Active connections: {Count}", + peerId, _activeConnections); + + // E2EE鍵交換を開始 + if (_settings.EnableE2ee) + { + await StartE2eeKeyExchangeAsync(peerId); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error handling peer connected event for {PeerId}", peerId); + } + }); + } + + private void OnPeerDisconnected(string peerId) + { + lock (_connectionLock) + { + if (_activeConnections > 0) + _activeConnections--; + } + + _peers.TryRemove(peerId, out _); + + _logger?.LogInformation("Peer disconnected: {PeerId}. Active connections: {Count}", + peerId, _activeConnections); + } + + private void HandleMessage(string peerId, byte[] data) + { + _ = Task.Run(async () => + { + try + { + await HandleDataChannelMessageAsync(peerId, data); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error handling message from {PeerId}", peerId); + } + }); + } + + private void SendIceCandidate(string peerId, IceCandidate candidate) + { + _ = Task.Run(async () => + { + try + { + await SendIceCandidateToSignalingAsync(peerId, candidate); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error sending ICE candidate for {PeerId}", peerId); + } + }); + } + + private void SendOffer(string peerId, string sdp) + { + _ = Task.Run(async () => + { + if (_hubConnection == null) return; + + try + { + await _hubConnection.InvokeAsync("SendOffer", peerId, sdp); + _logger?.LogDebug("Sent offer to {PeerId}", peerId); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error sending offer for {PeerId}", peerId); + } + }); + } + + private void SendAnswer(string peerId, string sdp) + { + _ = Task.Run(async () => + { + if (_hubConnection == null) return; + + try + { + await _hubConnection.InvokeAsync("SendAnswer", peerId, sdp); + _logger?.LogDebug("Sent answer to {PeerId}", peerId); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error sending answer for {PeerId}", peerId); + } + }); + } + + private void OnDataChannelOpen(string peerId, Peer peer) + { + _logger?.LogInformation("DataChannel opened with peer {PeerId}", peerId); + + lock (_connectionLock) + { + _activeConnections++; + } + + if (_settings.EnableE2ee) + { + _ = Task.Run(async () => + { + try + { + await StartE2eeKeyExchangeAsync(peerId); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error starting E2EE key exchange with {PeerId}", peerId); + } + }); + } + } + + private void OnDataChannelClosed(string peerId, Peer peer) + { + _logger?.LogInformation("DataChannel closed with peer {PeerId}", peerId); + + lock (_connectionLock) + { + if (_activeConnections > 0) + _activeConnections--; + } + } + + private async Task SendIceCandidateToSignalingAsync(string peerId, IceCandidate candidate) + { + if (_hubConnection == null) return; + + try + { + await _hubConnection.InvokeAsync("SendIceCandidate", + peerId, candidate.Candidate, candidate.SdpMid, candidate.SdpMlineIndex); + _logger?.LogDebug("Sent ICE candidate to signaling server for peer {PeerId}", peerId); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to send ICE candidate for peer {PeerId}", peerId); + } + } + private void SetupSignalREventHandlers() { // 登録完了 @@ -161,27 +359,21 @@ private void SetupSignalREventHandlers() }); // ICE候補受信 - _hubConnection!.On("ice-candidate", async data => + _hubConnection!.On("ice-candidate", async data => { await HandleIceCandidateAsync(data.FromPeerId, data.Candidate, data.SdpMid, data.SdpMlineIndex); }); // ピア接続完了 - _hubConnection!.On("peer-connected", async data => + _hubConnection!.On("peer-connected", async data => { - await OnPeerConnectedAsync(data.PeerId); + await OnPeerConnectedSignalRAsync(data.PeerId); }); // ピア切断 - _hubConnection!.On("peer-disconnected", data => - { - OnPeerDisconnected(data.PeerId); - }); - - // DataChannelメッセージ受信 - _hubConnection!.On("datachannel-message", async data => + _hubConnection!.On("peer-disconnected", data => { - await HandleDataChannelMessageAsync(data.FromPeerId, data.Payload); + OnPeerDisconnectedSignalR(data.PeerId); }); // E2EE鍵交換 @@ -203,7 +395,8 @@ private async Task RegisterAsync() capabilities = new Dictionary { ["e2ee"] = _settings.EnableE2ee, - ["version"] = "1.0" + ["version"] = "1.0", + ["backend"] = "lucid.rtc" } }); @@ -237,59 +430,73 @@ private async Task DiscoverAndConnectPeersAsync() private async Task InitiatePeerConnectionAsync(string peerId) { - if (_hubConnection == null || string.IsNullOrEmpty(_localPeerId)) return; + if (_rtcConnection == null || string.IsNullOrEmpty(_localPeerId)) return; _logger?.LogInformation("Initiating connection to peer: {PeerId}", peerId); - // Offer SDPを作成(シミュレーション - 実際のWebRTCではRTCPeerConnectionを使用) - var offerSdp = GenerateOfferSdp(); - - // Offerを送信 - await _hubConnection.InvokeAsync("SendOffer", peerId, offerSdp); + try + { + // Lucid.Rtc高レベルAPIでピア作成 + var peer = await _rtcConnection.CreatePeerAsync(peerId); + _peers[peerId] = peer; - // ピア接続状態を初期化 - _peerConnections[peerId] = new PeerConnectionState + // Offerは自動的に生成され、OfferReadyEventで送信される + _logger?.LogDebug("Created peer for {PeerId}, waiting for offer", peerId); + } + catch (Exception ex) { - PeerId = peerId, - IsDataChannelReady = false, - ConnectedAt = DateTime.UtcNow - }; + _logger?.LogWarning(ex, "Failed to initiate connection to {PeerId}", peerId); + } } private async Task HandleOfferAsync(string fromPeerId, string sdp) { - if (_hubConnection == null) return; + if (_rtcConnection == null) return; _logger?.LogDebug("Received offer from {PeerId}", fromPeerId); - // Answer SDPを作成(シミュレーション) - var answerSdp = GenerateAnswerSdp(sdp); - - // Answerを送信 - await _hubConnection.InvokeAsync("SendAnswer", fromPeerId, answerSdp); - - // ピア接続状態を更新 - _peerConnections[fromPeerId] = new PeerConnectionState + try { - PeerId = fromPeerId, - IsDataChannelReady = true, - ConnectedAt = DateTime.UtcNow - }; + // 既存のピアを取得、または新規作成 + Peer peer; + if (_peers.TryGetValue(fromPeerId, out var existingPeer)) + { + peer = existingPeer; + } + else + { + peer = await _rtcConnection.CreatePeerAsync(fromPeerId); + _peers[fromPeerId] = peer; + } - // 接続完了として処理 - await OnPeerConnectedAsync(fromPeerId); + // Offerを設定(Answerは自動的に生成され、AnswerReadyEventで送信される) + peer.SetRemoteOffer(sdp); + _logger?.LogDebug("Set remote offer for {PeerId}", fromPeerId); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to handle offer from {PeerId}", fromPeerId); + } } - private async Task HandleAnswerAsync(string fromPeerId, string sdp) + private Task HandleAnswerAsync(string fromPeerId, string sdp) { _logger?.LogDebug("Received answer from {PeerId}", fromPeerId); - // Answerを処理して接続完了 - if (_peerConnections.TryGetValue(fromPeerId, out var state)) + if (_peers.TryGetValue(fromPeerId, out var peer)) { - state.IsDataChannelReady = true; - await OnPeerConnectedAsync(fromPeerId); + try + { + peer.SetRemoteAnswer(sdp); + _logger?.LogDebug("Set remote answer for {PeerId}", fromPeerId); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to set remote answer for {PeerId}", fromPeerId); + } } + + return Task.CompletedTask; } private Task HandleIceCandidateAsync(string fromPeerId, string candidate, string sdpMid, int sdpMlineIndex) @@ -297,28 +504,41 @@ private Task HandleIceCandidateAsync(string fromPeerId, string candidate, string _logger?.LogDebug("Received ICE candidate from {PeerId}: {SdpMid}:{Index}", fromPeerId, sdpMid, sdpMlineIndex); - // ICE候補を処理(実際のWebRTC実装ではRTCPeerConnectionに追加) + if (_peers.TryGetValue(fromPeerId, out var peer)) + { + try + { + var iceCandidate = new IceCandidate + { + Candidate = candidate, + SdpMid = sdpMid, + SdpMlineIndex = sdpMlineIndex + }; + peer.AddIceCandidate(iceCandidate); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to add ICE candidate for {PeerId}", fromPeerId); + } + } + return Task.CompletedTask; } - private async Task OnPeerConnectedAsync(string peerId) + private Task OnPeerConnectedSignalRAsync(string peerId) { lock (_connectionLock) { _activeConnections++; } - _logger?.LogInformation("Peer connected: {PeerId}. Active connections: {Count}", + _logger?.LogInformation("Peer connected (SignalR): {PeerId}. Active connections: {Count}", peerId, _activeConnections); - // E2EE鍵交換を開始 - if (_settings.EnableE2ee) - { - await StartE2eeKeyExchangeAsync(peerId); - } + return Task.CompletedTask; } - private void OnPeerDisconnected(string peerId) + private void OnPeerDisconnectedSignalR(string peerId) { lock (_connectionLock) { @@ -326,8 +546,9 @@ private void OnPeerDisconnected(string peerId) _activeConnections--; } - _peerConnections.TryRemove(peerId, out _); - _logger?.LogInformation("Peer disconnected: {PeerId}. Active connections: {Count}", + _peers.TryRemove(peerId, out _); + + _logger?.LogInformation("Peer disconnected (SignalR): {PeerId}. Active connections: {Count}", peerId, _activeConnections); } @@ -337,17 +558,30 @@ private void ClearAllPeerConnections() { _activeConnections = 0; } - _peerConnections.Clear(); + + // Close all peers + foreach (var kvp in _peers) + { + try + { + kvp.Value.CloseAsync().Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error closing peer {PeerId}", kvp.Key); + } + } + + _peers.Clear(); } - private async Task HandleDataChannelMessageAsync(string fromPeerId, string payloadBase64) + private async Task HandleDataChannelMessageAsync(string fromPeerId, byte[] payload) { - if (string.IsNullOrEmpty(payloadBase64)) + if (payload == null || payload.Length == 0) return; try { - var payload = Convert.FromBase64String(payloadBase64); string content; if (_settings.EnableE2ee && _e2eeProvider.IsEncrypted) @@ -364,7 +598,7 @@ private async Task HandleDataChannelMessageAsync(string fromPeerId, string paylo { MessageId = messageId, SenderId = fromPeerId, - SenderName = $"Peer-{fromPeerId[..8]}", + SenderName = $"Peer-{fromPeerId[..Math.Min(8, fromPeerId.Length)]}", Content = content, ChannelId = "webrtc", Timestamp = DateTime.UtcNow, @@ -373,7 +607,8 @@ private async Task HandleDataChannelMessageAsync(string fromPeerId, string paylo Metadata = new Dictionary { ["peerId"] = fromPeerId, - ["encrypted"] = _settings.EnableE2ee && _e2eeProvider.IsEncrypted + ["encrypted"] = _settings.EnableE2ee && _e2eeProvider.IsEncrypted, + ["backend"] = "lucid.rtc" } }; @@ -394,8 +629,6 @@ private async Task StartE2eeKeyExchangeAsync(string peerId) { var result = await _e2eeProvider.StartKeyExchangeAsync(_cts?.Token ?? CancellationToken.None); - // ターゲットピアを指定して鍵交換 - // SignalingHubには特定ピアへの送信メソッドが必要 await _hubConnection.InvokeAsync("E2eeKeyExchange", peerId, result.SessionId, Convert.ToBase64String(result.PublicKey)); _logger?.LogDebug("E2EE key exchange initiated with {PeerId}", peerId); @@ -422,7 +655,6 @@ await _e2eeProvider.CompleteKeyExchangeAsync(new Abstractions.Security.KeyExchan _logger?.LogInformation("E2EE key exchange completed with {PeerId}", fromPeerId); - // 応答として自分の公開鍵を送信(まだ暗号化されていない場合) if (!_e2eeProvider.IsEncrypted) { await StartE2eeKeyExchangeAsync(fromPeerId); @@ -458,7 +690,7 @@ public async Task StopAsync(CancellationToken cancellationToken = default) public async Task SendMessageAsync(string message, string? replyToMessageId = null, CancellationToken cancellationToken = default) { - if (_hubConnection == null || string.IsNullOrEmpty(_localPeerId)) + if (_rtcConnection == null || string.IsNullOrEmpty(_localPeerId)) { _logger?.LogWarning("WebRTC not connected"); return; @@ -475,29 +707,35 @@ public async Task SendMessageAsync(string message, string? replyToMessageId = nu payload = Encoding.UTF8.GetBytes(message); } - var payloadBase64 = Convert.ToBase64String(payload); - - // 特定のピアに送信、またはブロードキャスト + // 特定のピアに送信(返信の場合) if (!string.IsNullOrEmpty(replyToMessageId) && _channelTracking.TryGetValue(replyToMessageId, out var targetPeerId)) { - // 特定のピアに返信 - await _hubConnection.InvokeAsync("SendDataChannelMessage", targetPeerId, payloadBase64, cancellationToken); - } - else - { - // すべての接続ピアにブロードキャスト - foreach (var peerId in _peerConnections.Keys) + if (_peers.TryGetValue(targetPeerId, out var peer)) { try { - await _hubConnection.InvokeAsync("SendDataChannelMessage", peerId, payloadBase64, cancellationToken); + peer.Send(payload); + _logger?.LogDebug("Sent message to peer {PeerId}", targetPeerId); } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to send message to peer {PeerId}", peerId); + _logger?.LogWarning(ex, "Failed to send message to peer {PeerId}", targetPeerId); } } } + else + { + // 全ピアにブロードキャスト + try + { + _rtcConnection.Broadcast(payload); + _logger?.LogDebug("Broadcast message to all peers"); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to broadcast message"); + } + } } public IStreamingMessageWriter StartStreamingMessage(CancellationToken cancellationToken = default) @@ -512,29 +750,15 @@ public async ValueTask DisposeAsync() _disposed = true; await StopAsync(); - _hubConnection?.DisposeAsync(); - _cts?.Dispose(); - } - // SDP生成(シミュレーション - 実際のWebRTC実装では本物のSDPを使用) - private string GenerateOfferSdp() - { - return $"v=0\r\n" + - $"o=- {Guid.NewGuid():N} 2 IN IP4 127.0.0.1\r\n" + - $"s=Clawleash WebRTC Offer\r\n" + - $"t=0 0\r\n" + - $"m=application 1 DTLS/SCTP 5000\r\n" + - $"c=IN IP4 0.0.0.0\r\n"; - } + if (_rtcConnection != null) + { + await _rtcConnection.DisposeAsync(); + _rtcConnection = null; + } - private string GenerateAnswerSdp(string offerSdp) - { - return $"v=0\r\n" + - $"o=- {Guid.NewGuid():N} 2 IN IP4 127.0.0.1\r\n" + - $"s=Clawleash WebRTC Answer\r\n" + - $"t=0 0\r\n" + - $"m=application 1 DTLS/SCTP 5000\r\n" + - $"c=IN IP4 0.0.0.0\r\n"; + _hubConnection?.DisposeAsync(); + _cts?.Dispose(); } } @@ -626,7 +850,7 @@ internal class AnswerEvent public string Sdp { get; set; } = string.Empty; } -internal class IceCandidateEvent +internal class SignalRIceCandidateEvent { public string FromPeerId { get; set; } = string.Empty; public string Candidate { get; set; } = string.Empty; @@ -634,23 +858,17 @@ internal class IceCandidateEvent public int SdpMlineIndex { get; set; } } -internal class PeerConnectedEvent +internal class PeerConnectedSignalREvent { public string PeerId { get; set; } = string.Empty; public string? RemotePeerId { get; set; } } -internal class PeerDisconnectedEvent +internal class PeerDisconnectedSignalREvent { public string PeerId { get; set; } = string.Empty; } -internal class DataChannelMessageEvent -{ - public string FromPeerId { get; set; } = string.Empty; - public string Payload { get; set; } = string.Empty; -} - internal class E2eeKeyExchangeEvent { public string FromPeerId { get; set; } = string.Empty; diff --git a/Clawleash.Interfaces.WebRTC/WebRtcSettings.cs b/Clawleash.Interfaces.WebRTC/WebRtcSettings.cs index b0912ea..689d952 100644 --- a/Clawleash.Interfaces.WebRTC/WebRtcSettings.cs +++ b/Clawleash.Interfaces.WebRTC/WebRtcSettings.cs @@ -50,4 +50,35 @@ public class WebRtcSettings : ChatInterfaceSettingsBase /// DataChannel名 /// public string DataChannelName { get; set; } = "clawleash-chat"; + + /// + /// 最大再接続試行回数 + /// + public int MaxReconnectAttempts { get; set; } = 5; + + /// + /// ネイティブWebRTCライブラリの使用を試みる + /// 無効な場合はシミュレーションモードで動作 + /// + public bool TryUseNativeClient { get; set; } = true; + + /// + /// ICE候補の収集タイムアウト(ミリ秒) + /// + public int IceGatheringTimeoutMs { get; set; } = 10000; + + /// + /// DataChannelの信頼性モード + /// + public bool DataChannelReliable { get; set; } = true; + + /// + /// ハートビート間隔(ミリ秒)- 0で無効 + /// + public int HeartbeatIntervalMs { get; set; } = 30000; + + /// + /// ピア接続アイドルタイムアウト(ミリ秒) + /// + public int PeerIdleTimeoutMs { get; set; } = 60000; } From aaaaf55b07aaad29870d0ab7e3a0dcb2b9086f49 Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:41:56 +0900 Subject: [PATCH 3/6] [add] test --- Clawleash.Tests/Clawleash.Tests.csproj | 1 + .../Interfaces/WebRtcSettingsTests.cs | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 Clawleash.Tests/Interfaces/WebRtcSettingsTests.cs diff --git a/Clawleash.Tests/Clawleash.Tests.csproj b/Clawleash.Tests/Clawleash.Tests.csproj index 2b6b71e..3992f09 100644 --- a/Clawleash.Tests/Clawleash.Tests.csproj +++ b/Clawleash.Tests/Clawleash.Tests.csproj @@ -22,6 +22,7 @@ + \ No newline at end of file diff --git a/Clawleash.Tests/Interfaces/WebRtcSettingsTests.cs b/Clawleash.Tests/Interfaces/WebRtcSettingsTests.cs new file mode 100644 index 0000000..7e83fcb --- /dev/null +++ b/Clawleash.Tests/Interfaces/WebRtcSettingsTests.cs @@ -0,0 +1,110 @@ +using Clawleash.Interfaces.WebRTC; + +namespace Clawleash.Tests.Interfaces; + +public class WebRtcSettingsTests +{ + [Fact] + public void DefaultSettings_HaveCorrectValues() + { + // Arrange & Act + var settings = new WebRtcSettings(); + + // Assert + Assert.Equal("ws://localhost:8080/signaling", settings.SignalingServerUrl); + Assert.Equal(2, settings.StunServers.Count); + Assert.Contains("stun:stun.l.google.com:19302", settings.StunServers); + Assert.Contains("stun:stun1.l.google.com:19302", settings.StunServers); + Assert.Null(settings.TurnServerUrl); + Assert.Null(settings.TurnUsername); + Assert.Null(settings.TurnPassword); + Assert.Equal(5000, settings.ReconnectIntervalMs); + Assert.Equal(30000, settings.IceConnectionTimeoutMs); + Assert.Equal("clawleash-chat", settings.DataChannelName); + Assert.Equal(5, settings.MaxReconnectAttempts); + Assert.True(settings.TryUseNativeClient); + Assert.Equal(10000, settings.IceGatheringTimeoutMs); + Assert.True(settings.DataChannelReliable); + Assert.Equal(30000, settings.HeartbeatIntervalMs); + Assert.Equal(60000, settings.PeerIdleTimeoutMs); + } + + [Fact] + public void StunServers_CanBeModified() + { + // Arrange + var settings = new WebRtcSettings(); + + // Act + settings.StunServers.Add("stun:stun2.l.google.com:19302"); + + // Assert + Assert.Equal(3, settings.StunServers.Count); + } + + [Fact] + public void TurnServer_CanBeConfigured() + { + // Arrange + var settings = new WebRtcSettings(); + + // Act + settings.TurnServerUrl = "turn:turn.example.com:3478"; + settings.TurnUsername = "user"; + settings.TurnPassword = "pass"; + + // Assert + Assert.Equal("turn:turn.example.com:3478", settings.TurnServerUrl); + Assert.Equal("user", settings.TurnUsername); + Assert.Equal("pass", settings.TurnPassword); + } + + [Fact] + public void SignalingServerUrl_CanBeChanged() + { + // Arrange + var settings = new WebRtcSettings(); + + // Act + settings.SignalingServerUrl = "https://example.com/signalr"; + + // Assert + Assert.Equal("https://example.com/signalr", settings.SignalingServerUrl); + } + + [Fact] + public void Timeouts_CanBeAdjusted() + { + // Arrange + var settings = new WebRtcSettings(); + + // Act + settings.IceConnectionTimeoutMs = 60000; + settings.ReconnectIntervalMs = 10000; + settings.IceGatheringTimeoutMs = 20000; + settings.HeartbeatIntervalMs = 15000; + settings.PeerIdleTimeoutMs = 120000; + + // Assert + Assert.Equal(60000, settings.IceConnectionTimeoutMs); + Assert.Equal(10000, settings.ReconnectIntervalMs); + Assert.Equal(20000, settings.IceGatheringTimeoutMs); + Assert.Equal(15000, settings.HeartbeatIntervalMs); + Assert.Equal(120000, settings.PeerIdleTimeoutMs); + } + + [Fact] + public void DataChannelSettings_CanBeConfigured() + { + // Arrange + var settings = new WebRtcSettings(); + + // Act + settings.DataChannelName = "custom-channel"; + settings.DataChannelReliable = false; + + // Assert + Assert.Equal("custom-channel", settings.DataChannelName); + Assert.False(settings.DataChannelReliable); + } +} From 5c601679c657ba2571c8a43b6f486f4fdd5b362f Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:42:29 +0900 Subject: [PATCH 4/6] [fix] ci --- .github/workflows/ci.yml | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c40c346 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [ master, main,feature/** ] + pull_request: + branches: [ master, main ] + workflow_dispatch: + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + # Build NuGet packages for interfaces + pack-interfaces: + needs: build + runs-on: windows-latest + strategy: + matrix: + include: + - project: Clawleash.Interfaces.WebRTC + - project: Clawleash.Interfaces.WebSocket + - project: Clawleash.Interfaces.Discord + - project: Clawleash.Interfaces.Slack + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore ${{ matrix.project }}/${{ matrix.project }}.csproj + + - name: Pack + continue-on-error: true + run: dotnet pack ${{ matrix.project }}/${{ matrix.project }}.csproj --configuration Release --output ./artifacts + + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.project }}-package + path: artifacts/*.nupkg + retention-days: 7 + if-no-files-found: ignore + + # Lint and format check + lint: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore + + - name: Format Check + run: dotnet format --verify-no-changes --verbosity diagnostic + continue-on-error: true From 4a7f21cfe73a9f4c608241f34dd8f52527a1178d Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:49:57 +0900 Subject: [PATCH 5/6] [fix] ci --- .github/workflows/ci.yml | 62 +++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40c346..185b009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ master, main,feature/** ] + branches: [ master, main, feature/** ] pull_request: branches: [ master, main ] workflow_dispatch: @@ -11,8 +11,8 @@ env: DOTNET_VERSION: '10.0.x' jobs: - build: - runs-on: windows-latest + build-and-test: + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 @@ -25,10 +25,10 @@ jobs: - name: Cache NuGet packages uses: actions/cache@v4 with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- - name: Restore dependencies run: dotnet restore @@ -39,17 +39,20 @@ jobs: - name: Test run: dotnet test --configuration Release --no-build --verbosity normal - # Build NuGet packages for interfaces + - name: Format Check + run: dotnet format --verify-no-changes --verbosity diagnostic + continue-on-error: true + pack-interfaces: - needs: build - runs-on: windows-latest + needs: build-and-test + runs-on: ubuntu-latest strategy: matrix: - include: - - project: Clawleash.Interfaces.WebRTC - - project: Clawleash.Interfaces.WebSocket - - project: Clawleash.Interfaces.Discord - - project: Clawleash.Interfaces.Slack + project: + - Clawleash.Interfaces.WebRTC + - Clawleash.Interfaces.WebSocket + - Clawleash.Interfaces.Discord + - Clawleash.Interfaces.Slack steps: - name: Checkout repository uses: actions/checkout@v4 @@ -59,11 +62,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Restore dependencies run: dotnet restore ${{ matrix.project }}/${{ matrix.project }}.csproj - name: Pack - continue-on-error: true run: dotnet pack ${{ matrix.project }}/${{ matrix.project }}.csproj --configuration Release --output ./artifacts - name: Upload package @@ -73,23 +83,3 @@ jobs: path: artifacts/*.nupkg retention-days: 7 if-no-files-found: ignore - - # Lint and format check - lint: - needs: build - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore dependencies - run: dotnet restore - - - name: Format Check - run: dotnet format --verify-no-changes --verbosity diagnostic - continue-on-error: true From c18a3ab56a58baf7df478c9ce20877b74a685eb2 Mon Sep 17 00:00:00 2001 From: actbit <57023457+actbit@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:50:10 +0900 Subject: [PATCH 6/6] [add] package --- Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj b/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj index 8a27a80..51d4c62 100644 --- a/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj +++ b/Clawleash.Interfaces.WebRTC/Clawleash.Interfaces.WebRTC.csproj @@ -37,6 +37,7 @@ +