Streamingle_URP/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs
user 53fa19e834 Improve: 카메라 프리뷰 모바일 대응 + WebSocket 안정성 강화
- iOS/iPadOS 감지 → base64 텍스트 JSON 프리뷰 전송 (바이너리 WS 회피)
- Android/PC는 기존 바이너리 패킷 유지 (클라이언트별 포맷 분기)
- 양방향 WebSocket keepalive (클라이언트 ping 3초 + 서버 keepalive 5초)
- websocket-sharp KeepClean + WaitTime 설정
- 하트비트 타임아웃 완화 (12초) + 재연결 지수 백오프
- 재연결 후 프리뷰 구독 딜레이 (iOS 1.5초)
- SendMessage/SendBinary에 연결 상태 체크 추가
- 모바일 CSS 반응형 (태블릿/폰 터치 타겟, 그리드, 풀스크린 safe-area)
- 대시보드 Cache-Control 강화 (no-store)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:35:38 +09:00

283 lines
8.8 KiB
C#

using System;
using System.Collections.Generic;
using System.Net;
using System.Text;
using System.Threading;
using UnityEngine;
/// <summary>
/// HTTP 서버 - Streamingle 웹 대시보드 UI를 제공합니다.
/// Resources/StreamingleDashboard/ 폴더에서 HTML, CSS, JS를 로드합니다.
/// RetargetingHTTPServer 패턴을 따릅니다.
/// </summary>
public class StreamingleDashboardServer
{
private HttpListener listener;
private Thread listenerThread;
private bool isRunning = false;
private int httpPort;
private int wsPort;
private int retargetingWsPort;
private string cachedCSS = "";
private string cachedTemplate = "";
private string cachedJS = "";
private List<string> boundAddresses = new List<string>();
public bool IsRunning => isRunning;
public IReadOnlyList<string> BoundAddresses => boundAddresses;
public StreamingleDashboardServer(int httpPort, int wsPort, int retargetingWsPort = 0)
{
this.httpPort = httpPort;
this.wsPort = wsPort;
this.retargetingWsPort = retargetingWsPort;
}
public void Start()
{
if (isRunning) return;
LoadResources();
try
{
listener = new HttpListener();
boundAddresses.Clear();
// http://+: 로 모든 인터페이스 바인딩 시도 (관리자 권한 또는 URL ACL 필요)
bool wildcardBound = false;
try
{
listener.Prefixes.Add($"http://+:{httpPort}/");
listener.Start();
wildcardBound = true;
boundAddresses.Add($"http://localhost:{httpPort}");
string localIP = GetLocalIPAddress();
if (!string.IsNullOrEmpty(localIP))
{
boundAddresses.Add($"http://{localIP}:{httpPort}");
}
Debug.Log("[StreamingleDashboard] 모든 인터페이스 바인딩 성공 (LAN 접속 가능)");
}
catch (Exception)
{
// 와일드카드 실패 시 리스너 재생성
try { listener.Close(); } catch { }
listener = new HttpListener();
// LAN IP 직접 바인딩 시도
string localIP = GetLocalIPAddress();
bool lanBound = false;
listener.Prefixes.Add($"http://localhost:{httpPort}/");
boundAddresses.Add($"http://localhost:{httpPort}");
listener.Prefixes.Add($"http://127.0.0.1:{httpPort}/");
boundAddresses.Add($"http://127.0.0.1:{httpPort}");
if (!string.IsNullOrEmpty(localIP))
{
try
{
listener.Prefixes.Add($"http://{localIP}:{httpPort}/");
boundAddresses.Add($"http://{localIP}:{httpPort}");
lanBound = true;
}
catch (Exception)
{
// LAN IP 바인딩도 실패
}
}
listener.Start();
if (lanBound)
Debug.Log($"[StreamingleDashboard] LAN IP 바인딩 성공 ({localIP})");
else
Debug.LogWarning("[StreamingleDashboard] LAN 바인딩 실패. localhost만 접속 가능. 관리자 권한으로 실행하거나 다음 명령어를 실행하세요:\n" +
$" netsh http add urlacl url=http://+:{httpPort}/ user=Everyone");
}
isRunning = true;
listenerThread = new Thread(HandleRequests);
listenerThread.IsBackground = true;
listenerThread.Start();
Debug.Log("[StreamingleDashboard] HTTP 서버 시작됨");
foreach (var addr in boundAddresses)
{
Debug.Log($"[StreamingleDashboard] 대시보드 접속: {addr}");
}
}
catch (Exception ex)
{
Debug.LogError($"[StreamingleDashboard] 서버 시작 실패: {ex.Message}");
}
}
public void Stop()
{
if (!isRunning) return;
isRunning = false;
try
{
listener?.Stop();
listener?.Close();
}
catch (Exception) { }
Debug.Log("[StreamingleDashboard] HTTP 서버 중지됨");
}
private void LoadResources()
{
TextAsset cssAsset = Resources.Load<TextAsset>("StreamingleDashboard/dashboard_style");
TextAsset templateAsset = Resources.Load<TextAsset>("StreamingleDashboard/dashboard_template");
TextAsset jsAsset = Resources.Load<TextAsset>("StreamingleDashboard/dashboard_script");
cachedCSS = cssAsset != null ? cssAsset.text : GetFallbackCSS();
cachedTemplate = templateAsset != null ? templateAsset.text : GetFallbackTemplate();
cachedJS = jsAsset != null ? jsAsset.text : GetFallbackJS();
if (cssAsset == null || templateAsset == null || jsAsset == null)
{
Debug.LogWarning("[StreamingleDashboard] 일부 리소스를 로드할 수 없습니다. Fallback 사용 중.");
}
}
private void HandleRequests()
{
while (isRunning)
{
try
{
HttpListenerContext context = listener.GetContext();
ProcessRequest(context);
}
catch (HttpListenerException)
{
// 서버 종료 시 발생
}
catch (Exception ex)
{
if (isRunning)
{
Debug.LogError($"[StreamingleDashboard] 요청 처리 오류: {ex.Message}");
}
}
}
}
private void ProcessRequest(HttpListenerContext context)
{
try
{
string path = context.Request.Url.AbsolutePath;
string responseContent;
string contentType;
if (path == "/" || path == "/index.html")
{
responseContent = GenerateHTML();
contentType = "text/html; charset=utf-8";
}
else if (path == "/style.css")
{
responseContent = cachedCSS;
contentType = "text/css; charset=utf-8";
}
else if (path == "/script.js")
{
responseContent = cachedJS
.Replace("{{WS_PORT}}", wsPort.ToString())
.Replace("{{RETARGETING_WS_PORT}}", retargetingWsPort.ToString());
contentType = "application/javascript; charset=utf-8";
}
else
{
context.Response.StatusCode = 404;
responseContent = "Not Found";
contentType = "text/plain";
}
byte[] buffer = Encoding.UTF8.GetBytes(responseContent);
context.Response.ContentType = contentType;
context.Response.ContentLength64 = buffer.Length;
context.Response.AddHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
context.Response.Close();
}
catch (Exception ex)
{
Debug.LogError($"[StreamingleDashboard] 응답 처리 오류: {ex.Message}");
}
}
private string GenerateHTML()
{
string html = cachedTemplate;
html = html.Replace("{{CSS}}", cachedCSS);
html = html.Replace("{{JS}}", cachedJS
.Replace("{{WS_PORT}}", wsPort.ToString())
.Replace("{{RETARGETING_WS_PORT}}", retargetingWsPort.ToString()));
return html;
}
private string GetLocalIPAddress()
{
try
{
var host = Dns.GetHostEntry(Dns.GetHostName());
foreach (var ip in host.AddressList)
{
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
return ip.ToString();
}
}
}
catch (Exception) { }
return "";
}
private string GetFallbackCSS()
{
return @"
body { font-family: sans-serif; background: #0f172a; color: #f1f5f9; padding: 20px; }
.error { color: #ef4444; padding: 20px; text-align: center; }
";
}
private string GetFallbackTemplate()
{
return @"
<!DOCTYPE html>
<html>
<head>
<meta charset=""UTF-8"">
<title>Streamingle Dashboard</title>
<style>{{CSS}}</style>
</head>
<body>
<div class=""error"">
리소스 파일을 찾을 수 없습니다.<br>
Assets/Resources/StreamingleDashboard/ 폴더에 파일이 있는지 확인해주세요.
</div>
</body>
</html>";
}
private string GetFallbackJS()
{
return "console.error('JavaScript resource not found');";
}
}