66using H . NotifyIcon ;
77using Musebase . Windows . Overlay ;
88using Musebase . Windows . Services ;
9+ using Musebase . Browser ;
910using Musebase . Engine ;
1011using Velopack ;
1112
@@ -87,6 +88,9 @@ private static void Main(string[] args)
8788 H . NotifyIcon . TaskbarIcon ? tray = null ;
8889 MiniWindow ? miniWindow = null ;
8990
91+ // 브라우저 디스플레이 서버(인프로세스 Kestrel). null=꺼짐. 토글/자동시작/종료 경로가 공유.
92+ BrowserDisplayServer ? browserServer = null ;
93+
9094 // 번역 실패 사용자 힌트: 같은 kind는 세션 1회만(곡마다 스팸 금지). 로그는 매 실패 기록.
9195 var reportedFailureKinds = new HashSet < Musebase . Core . Translation . TranslatorFailureKind > ( ) ;
9296 static string LocalizeFailureHint ( Musebase . Core . Translation . TranslatorFailureKind kind ) => kind switch
@@ -164,6 +168,7 @@ void OnTranslationFailure(Musebase.Core.Translation.TranslatorFailure f)
164168 var overlayToggle = new MenuItem { Header = Loc . T ( "tray.overlay.show" ) , IsCheckable = true , IsChecked = settings . OverlayVisible } ;
165169 var moveToggle = new MenuItem { Header = Loc . T ( "tray.overlay.moveMode" ) , IsCheckable = true } ;
166170 var sourceMenu = new MenuItem { Header = Loc . T ( "tray.source" ) } ;
171+ var browserDisplayToggle = new MenuItem { Header = Loc . T ( "tray.browserDisplay" ) , IsCheckable = true , IsChecked = settings . BrowserDisplayEnabled } ;
167172
168173 void ApplySource ( string mode , bool includeBrowsers )
169174 {
@@ -428,9 +433,94 @@ void ReviveOverlay()
428433 void ExitApp ( )
429434 {
430435 miniWindow ? . CloseForExit ( ) ;
431- app . Shutdown ( ) ;
436+ app . Shutdown ( ) ; // 브라우저 서버 정리는 app.Exit 핸들러에서(설정은 유지 → 다음 실행 자동 시작)
437+ }
438+
439+ // ---- 브라우저 디스플레이(태블릿/TV) 수명주기 ----
440+ // 실제 접속용 URL(LAN이면 사설 IPv4, 아니면 localhost) + 실제 바인딩 포트(포트=0 대응).
441+ string BrowserDisplayUrl ( BrowserDisplayServer server )
442+ {
443+ var port = settings . BrowserDisplayPort ;
444+ var bound = server . Urls
445+ . Select ( u => Uri . TryCreate (
446+ u . Replace ( "0.0.0.0" , "localhost" ) . Replace ( "[::]" , "localhost" ) . Replace ( "+" , "localhost" ) ,
447+ UriKind . Absolute , out var uri ) ? uri : null )
448+ . FirstOrDefault ( u => u is not null ) ;
449+ if ( bound is not null ) port = bound . Port ;
450+ var host = settings . BrowserDisplayLan && LocalLanIPv4 ( ) is { } ip ? ip : "localhost" ;
451+ return $ "http://{ host } :{ port } ";
452+ }
453+
454+ async Task StartBrowserDisplayAsync ( bool userInitiated )
455+ {
456+ if ( browserServer is not null ) return ; // 이미 실행 중
457+ try
458+ {
459+ browserServer = await BrowserDisplayServer . StartAsync ( new BrowserDisplayOptions (
460+ Port : settings . BrowserDisplayPort ,
461+ ListenLan : settings . BrowserDisplayLan ,
462+ Log : Log . Write ) ) ;
463+ coordinator . StateChanged += browserServer . Publish ; // 표시 상태 방송 연결
464+ browserServer . Publish ( coordinator . CurrentState ) ; // 접속 전이라도 현재 상태 1회 반영
465+ telemetry . CountFeature ( "browserDisplay" ) ;
466+
467+ var url = BrowserDisplayUrl ( browserServer ) ;
468+ settings . BrowserDisplayEnabled = true ;
469+ settings . Save ( ) ;
470+ browserDisplayToggle . IsChecked = true ;
471+ Log . Write ( $ "[browser] 시작: 리슨={ string . Join ( ", " , browserServer . Urls ) } 안내URL={ url } ") ;
472+
473+ // URL 노출: 트레이 툴팁·미니창 상태 + (사용자 조작 시) 안내 다이얼로그.
474+ var statusText = Loc . T ( "browserDisplay.status.on" , ( "url" , url ) ) ;
475+ if ( tray is { } t ) t . ToolTipText = Loc . T ( "tray.tooltip.status" , ( "status" , statusText ) ) ;
476+ miniWindow ? . SetStatus ( statusText ) ;
477+ if ( userInitiated )
478+ MessageBox . Show (
479+ Loc . T ( "browserDisplay.dialog.body" , ( "url" , url ) ) ,
480+ Loc . T ( "browserDisplay.dialog.title" ) ,
481+ MessageBoxButton . OK , MessageBoxImage . Information ) ;
482+ }
483+ catch ( Exception e )
484+ {
485+ Log . Write ( $ "[browser] 시작 실패: { e . Message } ") ;
486+ if ( browserServer is { } failed )
487+ {
488+ coordinator . StateChanged -= failed . Publish ;
489+ try { await failed . DisposeAsync ( ) ; } catch { /* 정리 실패 무시 */ }
490+ browserServer = null ;
491+ }
492+ settings . BrowserDisplayEnabled = false ;
493+ settings . Save ( ) ;
494+ browserDisplayToggle . IsChecked = false ;
495+ MessageBox . Show (
496+ Loc . T ( "browserDisplay.fail" , ( "port" , settings . BrowserDisplayPort . ToString ( ) ) , ( "error" , e . Message ) ) ,
497+ Loc . T ( "browserDisplay.dialog.title" ) ,
498+ MessageBoxButton . OK , MessageBoxImage . Warning ) ;
499+ }
432500 }
433501
502+ async Task StopBrowserDisplayAsync ( bool save )
503+ {
504+ if ( browserServer is not { } server ) return ;
505+ coordinator . StateChanged -= server . Publish ;
506+ browserServer = null ;
507+ try { await server . DisposeAsync ( ) ; }
508+ catch ( Exception e ) { Log . Write ( $ "[browser] 정지 실패: { e . Message } ") ; }
509+ if ( save )
510+ {
511+ settings . BrowserDisplayEnabled = false ;
512+ settings . Save ( ) ;
513+ }
514+ browserDisplayToggle . IsChecked = false ;
515+ Log . Write ( "[browser] 정지" ) ;
516+ }
517+
518+ browserDisplayToggle . Click += async ( _ , _ ) =>
519+ {
520+ if ( browserDisplayToggle . IsChecked ) await StartBrowserDisplayAsync ( userInitiated : true ) ;
521+ else await StopBrowserDisplayAsync ( save : true ) ;
522+ } ;
523+
434524 overlayToggle . Click += ( _ , _ ) => SetOverlayVisible ( overlayToggle . IsChecked ) ;
435525 moveToggle . Click += ( _ , _ ) => overlay . SetMoveMode ( moveToggle . IsChecked ) ;
436526 overlay . MoveModeChanged += moveMode => moveToggle . IsChecked = moveMode ; // 자물쇠 버튼과 동기화
@@ -468,6 +558,7 @@ void AdjustOffset(double? delta)
468558 menu . Items . Add ( overlayToggle ) ;
469559 menu . Items . Add ( moveToggle ) ;
470560 menu . Items . Add ( sourceMenu ) ;
561+ menu . Items . Add ( browserDisplayToggle ) ;
471562 menu . Items . Add ( new Separator ( ) ) ;
472563 menu . Items . Add ( offsetLabel ) ;
473564 menu . Items . Add ( offsetPlus ) ;
@@ -529,6 +620,13 @@ void AdjustOffset(double? delta)
529620
530621 app . Exit += ( _ , _ ) =>
531622 {
623+ // 브라우저 서버 정리: 종료 직전 Kestrel을 멈춰 포트를 해제(설정은 유지 → 다음 실행 자동 시작).
624+ if ( browserServer is { } bs )
625+ {
626+ try { bs . DisposeAsync ( ) . AsTask ( ) . Wait ( TimeSpan . FromSeconds ( 2 ) ) ; }
627+ catch { /* 종료 정리 실패 무시 */ }
628+ browserServer = null ;
629+ }
532630 tray . Dispose ( ) ;
533631 appIcon . Dispose ( ) ;
534632 telemetry . Dispose ( ) ; // 세션 feature_use 카운터를 큐로 보존(다음 실행에서 업로드)
@@ -611,6 +709,7 @@ void ApplyMenuText()
611709 overlayToggle . Header = Loc . T ( "tray.overlay.show" ) ;
612710 moveToggle . Header = Loc . T ( "tray.overlay.moveMode" ) ;
613711 sourceMenu . Header = Loc . T ( "tray.source" ) ;
712+ browserDisplayToggle . Header = Loc . T ( "tray.browserDisplay" ) ;
614713 offsetPlus . Header = Loc . T ( "tray.offset.faster" ) ;
615714 offsetMinus . Header = Loc . T ( "tray.offset.slower" ) ;
616715 offsetReset . Header = Loc . T ( "tray.offset.reset" ) ;
@@ -641,6 +740,10 @@ void ApplyMenuText()
641740 coordinator . Start ( ) ; // 배선 완료 후 시작 (캐시/번역/상태 이벤트 유효)
642741 Log . Write ( "=== Musebase 시작 ===" ) ;
643742
743+ // 브라우저 디스플레이 자동 시작(설정 켜짐 시). 실패해도 앱 다른 기능엔 영향 없음.
744+ if ( settings . BrowserDisplayEnabled )
745+ _ = StartBrowserDisplayAsync ( userInitiated : false ) ;
746+
644747 // 텔레메트리 동의 다이얼로그(최초 1회, 기본 모두 꺼짐 = 미수집)
645748 if ( ! settings . TelemetryConsentAsked )
646749 new TelemetryConsentWindow ( settings ) . Show ( ) ;
@@ -651,6 +754,32 @@ void ApplyMenuText()
651754 app . Run ( ) ;
652755 }
653756
757+ /// <summary>
758+ /// 이 PC의 사설대역 IPv4 주소(LAN 접속용). 여러 개면 첫 번째. 없으면 null(→ 호출부는 localhost 사용).
759+ /// 10.x / 172.16–31.x / 192.168.x 만 후보로 삼아 공인/링크로컬 주소를 배제한다.
760+ /// </summary>
761+ private static string ? LocalLanIPv4 ( )
762+ {
763+ try
764+ {
765+ foreach ( var ip in System . Net . Dns . GetHostAddresses ( System . Net . Dns . GetHostName ( ) ) )
766+ {
767+ if ( ip . AddressFamily != System . Net . Sockets . AddressFamily . InterNetwork ) continue ;
768+ var b = ip . GetAddressBytes ( ) ;
769+ var isPrivate =
770+ b [ 0 ] == 10 ||
771+ ( b [ 0 ] == 172 && b [ 1 ] >= 16 && b [ 1 ] <= 31 ) ||
772+ ( b [ 0 ] == 192 && b [ 1 ] == 168 ) ;
773+ if ( isPrivate ) return ip . ToString ( ) ;
774+ }
775+ }
776+ catch
777+ {
778+ // 주소 조회 실패 시 localhost 폴백
779+ }
780+ return null ;
781+ }
782+
654783 /// <summary>파일명으로 쓸 수 없는 문자를 '_'로 치환한다.</summary>
655784 private static string SanitizeFileName ( string name )
656785 {
0 commit comments