M0: remote access server - Kestrel embedded (token auth/pair/queue API/library/range streaming/web companion zero-install), QR pairing window, DownloadQueueAdapter, auto-start+palette commands. Server E2E green (102 tests)

This commit is contained in:
Yun Chan 2026-08-15 15:30:35 +09:00
parent 40c1b39b70
commit bb569acd96
15 changed files with 734 additions and 0 deletions

View file

@ -0,0 +1,65 @@
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using QRCoder;
using VideoDownloader.Server;
namespace VideoDownloader.App.Browser;
/// <summary>M0 원격 접근 페어링: QR(주소+토큰 동시 전달) + 상태 표시.</summary>
public sealed class PairingWindow : Window
{
public PairingWindow(ServerHost server, string deviceName)
{
Title = "기기 연결 — Video Downloader";
Width = 420; Height = 560;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
Background = System.Windows.Media.Brushes.White;
var lanIp = GetLanIp();
var url = $"http://{lanIp}:{server.Port}/#t={server.Token}";
var qrPayload = new QRCodeGenerator().CreateQrCode(url, QRCodeGenerator.ECCLevel.M);
var qr = new QRCode(qrPayload);
var bmp = qr.GetGraphic(12);
var stack = new StackPanel { Margin = new Thickness(24) };
var title = new TextBlock { Text = "📱 폰 카메라로 QR 스캔", FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 4) };
var sub = new TextBlock { Text = "브라우저에서 바로 열립니다(앱 설치 불필요). 같은 Wi-Fi 에 있어야 합니다.", FontSize = 12, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) };
var img = new Image { Width = 300, Height = 300 };
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
ms.Position = 0;
img.Source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
var urlBox = new TextBox { Text = url, IsReadOnly = true, FontSize = 11, Margin = new Thickness(0, 12, 0, 4), TextWrapping = TextWrapping.Wrap };
var copyBtn = new Button { Content = "주소 복사", Padding = new Thickness(14, 6, 14, 6), HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 8, 0, 0) };
copyBtn.Click += (_, _) => { try { Clipboard.SetText(url); } catch { } };
var note = new TextBlock { Text = $"포트 {server.Port} · Windows 방화벽 확인이 뜨면 '허용'을 선택하세요\n토큰은 이 기기와 페어링된 폰에서만 유효합니다",
FontSize = 11, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) };
stack.Children.Add(title);
stack.Children.Add(sub);
stack.Children.Add(img);
stack.Children.Add(urlBox);
stack.Children.Add(copyBtn);
stack.Children.Add(note);
Content = stack;
}
/// <summary>LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback.</summary>
private static string GetLanIp()
{
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect("8.8.8.8", 65530); // 외부 행선지 지정 → 라우팅되는 인터페이스 IP 획득(패킷 전송 없음)
return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString() ?? "127.0.0.1";
}
catch { return "127.0.0.1"; }
}
}

View file

@ -18,6 +18,7 @@ namespace VideoDownloader.App.Core;
public class DownloadManager
{
private readonly AppConfig _config;
public AppConfig Config => _config;
private readonly Func<Uri, Task<List<CoreWebView2Cookie>>> _getCookies;
private readonly Action<string> _log;

View file

@ -40,6 +40,9 @@ public partial class MainWindow : Window
private readonly ObservableCollection<HistoryEntry> _history = new();
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// M0: 원격 접근 서버(모바일 연동)
private Server.ServerHost? _remoteServer;
// Phase D: 미디어 재생목록 (T2-10)
private readonly MediaPlaylist _playlist = new();
private PlayerWindow? _player;
@ -243,6 +246,23 @@ public partial class MainWindow : Window
};
_speedTimer.Start();
// M0: 설정돼 있으면 원격 접근 서버 자동 시작
if (_config.RemoteAccessEnabled && !string.IsNullOrEmpty(_config.RemoteAccessToken))
{
try
{
_remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions
{
Port = 0,
Token = _config.RemoteAccessToken!,
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
});
Log($"원격 접근 서버 포트 {_remoteServer.Port}");
}
catch { }
}
// T2-2 탭 절전: 1분마다 유휴(10분+) 비활성 탭 서스펜드
_suspendService = new TabSuspendService(TimeSpan.FromMinutes(10), tab =>
{
@ -879,6 +899,7 @@ public partial class MainWindow : Window
// T1-10 세션 저장: 탭 URL/활성/핀 보존
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10
try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0
}
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
@ -1152,6 +1173,8 @@ public partial class MainWindow : Window
_palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs()));
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
_palette.RegisterSeparator();
_palette.RegisterDynamic(() => _tabs.Tabs
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
@ -1486,6 +1509,56 @@ public partial class MainWindow : Window
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
}
// ---- M0 원격 접근(모바일 연동) ----
private async Task ToggleRemoteAccess()
{
if (_remoteServer != null)
{
await _remoteServer.DisposeAsync();
_remoteServer = null;
_config.RemoteAccessEnabled = false;
ConfigStore.Save(_config);
Log("원격 접근을 껐습니다");
return;
}
var token = _config.RemoteAccessToken;
if (string.IsNullOrEmpty(token))
{
token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(24))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
_config.RemoteAccessToken = token;
}
_config.RemoteAccessEnabled = true;
ConfigStore.Save(_config);
try
{
_remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions
{
Port = 0,
Token = token,
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
});
Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}");
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
pair.Show();
}
catch (Exception ex)
{
_remoteServer = null;
Log($"원격 접근 시작 실패(방화벽 확인): {ex.Message}");
}
}
private async void ShowPairing()
{
if (_remoteServer == null) { await ToggleRemoteAccess(); return; }
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
pair.Show();
}
// ---- T2-10 미디어 재생목록 ----
private void AddToPlaylist(string url, string title, string kind, string? referer)
{

View file

@ -0,0 +1,67 @@
using System.Windows.Threading;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Server;
using VideoDownloader.Core.Detection;
namespace VideoDownloader.App.Services;
/// <summary>
/// DownloadManager(UI 컬렉션) ↔ 서버 큐 API 브릿지. 모든 접근을 UI 디스패처로 마샬링.
/// </summary>
public sealed class DownloadQueueAdapter : IQueueAdapter
{
private readonly Core.DownloadManager _manager;
private readonly Dispatcher _dispatcher;
public DownloadQueueAdapter(Core.DownloadManager manager, Dispatcher dispatcher)
{
_manager = manager;
_dispatcher = dispatcher;
}
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => _dispatcher.InvokeAsync(() =>
(IReadOnlyList<QueueItemDto>)_manager.Items.Select(Map).ToList()).Task;
public Task<string> AddAsync(string url, string? title)
{
// 서버에서 온 URL → 감지 파이프라인으로(소셜/HLS/DASH 자동 분류)
return _dispatcher.InvokeAsync(() =>
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
return normalized;
}
var kind = MediaDetector.Classify(url, "", -1, _manager.Config) ?? MediaKind.Direct;
_manager.OnDetected(new DetectionContext(url, url, kind, ""));
return url;
}).Task;
}
public Task PauseAsync(string id) => _dispatcher.InvokeAsync(() =>
{
var item = Find(id);
if (item != null) _manager.Cancel(item); // v1: 제어 매핑 — 진행 항목 취소
}).Task;
public Task ResumeAsync(string id) => _dispatcher.InvokeAsync(() =>
{
var item = Find(id);
if (item != null) _ = _manager.RetryDownload(item);
}).Task;
public Task CancelAsync(string id) => _dispatcher.InvokeAsync(() =>
{
var item = Find(id);
if (item != null) _manager.Cancel(item);
}).Task;
public event Action? Changed { add { } remove { } }
private DownloadItemViewModel? Find(string id) =>
_manager.Items.FirstOrDefault(i => i.Id == id);
private static QueueItemDto Map(DownloadItemViewModel i) =>
new(i.Id, i.Title, i.Url ?? "", i.Status ?? "", i.Progress);
}

View file

@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
<ProjectReference Include="..\VideoDownloader.Browser\VideoDownloader.Browser.csproj" />
<ProjectReference Include="..\VideoDownloader.Server\VideoDownloader.Server.csproj" />
</ItemGroup>
<!-- .NET 10 SDK 의 net8.0-windows WPF implicit usings 보강 -->
@ -32,6 +33,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.Speech" Version="8.0.0" />
</ItemGroup>

View file

@ -19,6 +19,11 @@ public class DownloadItemViewModel : INotifyPropertyChanged
private string _title;
public string Title { get => _title; set => SetField(ref _title, value); }
/// <summary>항목 고유 ID(원격 API 노출용).</summary>
public string Id { get; } = Guid.NewGuid().ToString("N");
/// <summary>미디어 URL(원격 API 노출용).</summary>
public string? Url => MediaUrl?.ToString();
public string FormatTag { get; init; } = "FILE";
// 다운로드 실행에 필요한 컨텍스트 (감지 시 고정)