Refactor authentication (#348)

This commit is contained in:
Oleksii Holub 2023-07-10 20:19:52 +03:00 committed by GitHub
parent eb1bca6271
commit 6641d7d484
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 459 additions and 268 deletions

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Gress;
@ -14,7 +15,10 @@ namespace YoutubeDownloader.Core.Downloading;
public class VideoDownloader
{
private readonly YoutubeClient _youtube = new(Http.Client);
private readonly YoutubeClient _youtube;
public VideoDownloader(HttpClient http) =>
_youtube = new YoutubeClient(http);
public async Task<IReadOnlyList<VideoDownloadOption>> GetDownloadOptionsAsync(
VideoId videoId,

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Gress;
@ -15,14 +16,17 @@ namespace YoutubeDownloader.Core.Resolving;
public class QueryResolver
{
private readonly YoutubeClient _youtube = new(Http.Client);
private readonly YoutubeClient _youtube;
public QueryResolver(HttpClient http) =>
_youtube = new YoutubeClient(http);
public async Task<QueryResult> ResolveAsync(
string query,
CancellationToken cancellationToken = default)
{
// Only consider URLs when parsing IDs.
// All other queries should be treated as search queries.
// Only consider URLs for parsing IDs.
// All other queries should be treated as search keywords.
var isUrl = Uri.IsWellFormedUriString(query, UriKind.Absolute);
// Playlist

View File

@ -1,76 +0,0 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace YoutubeDownloader.Core.Utils;
public class AuthHandler : DelegatingHandler
{
private const string Origin = "https://www.youtube.com";
private readonly Uri _baseUri = new(Origin);
private readonly HttpClientHandler _innerHandler = new()
{
UseCookies = true,
CookieContainer = new CookieContainer()
};
public AuthHandler() => InnerHandler = _innerHandler;
public string? PageId { get; set; }
public void SetCookies(string cookies)
{
foreach (Cookie cookie in _innerHandler.CookieContainer.GetCookies(_baseUri))
cookie.Expired = true;
if (!string.IsNullOrWhiteSpace(cookies))
_innerHandler.CookieContainer.SetCookies(_baseUri, cookies);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var sapisid = _innerHandler.CookieContainer.GetCookies(_baseUri)["__Secure-3PAPISID"] ?? _innerHandler.CookieContainer.GetCookies(_baseUri)["SAPISID"];
if (sapisid is null)
return base.SendAsync(request, cancellationToken);
if(_innerHandler.CookieContainer.GetCookies(_baseUri)["SAPISID"] is null)
_innerHandler.CookieContainer.Add(_baseUri, new Cookie("SAPISID", sapisid.Value));
request.Headers.Remove("Authorization");
request.Headers.Remove("Origin");
request.Headers.Remove("X-Origin");
request.Headers.Add("Authorization", $"SAPISIDHASH {GenerateSidBasedAuth(sapisid.Value, Origin)}");
request.Headers.Add("Origin", Origin);
request.Headers.Add("X-Origin", Origin);
//Set to 0 as it is only allowed to be logged in with one account
request.Headers.Add("X-Goog-AuthUser", "0");
//Needed if there are brand accounts (Secondary channels)
if (PageId is not null)
request.Headers.Add("X-Goog-PageId", PageId);
return base.SendAsync(request, cancellationToken);
}
private static string GenerateSidBasedAuth(string sid, string origin)
{
var date = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var timestamp = date / 1000;
var sidHash = Hash($"{timestamp} {sid} {origin}");
return $"{timestamp}_{sidHash}";
}
private static string Hash(string input)
{
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(input));
return string.Concat(hash.Select(b => b.ToString("x2")));
}
}

View File

@ -0,0 +1,16 @@
using System.Text;
namespace YoutubeDownloader.Core.Utils.Extensions;
internal static class BinaryExtensions
{
public static string ToHex(this byte[] data)
{
var buffer = new StringBuilder(2 * data.Length);
foreach (var b in data)
buffer.Append(b.ToString("x2"));
return buffer.ToString();
}
}

View File

@ -2,13 +2,11 @@
namespace YoutubeDownloader.Core.Utils.Extensions;
public static class CollectionExtensions
internal static class CollectionExtensions
{
public static void AddRange<T>(this ICollection<T> source, IEnumerable<T> items)
{
foreach (var i in items)
source.Add(i);
}
public static string Join<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
}

View File

@ -6,19 +6,21 @@ namespace YoutubeDownloader.Core.Utils;
public static class Http
{
public static AuthHandler AuthHandler { get; } = new();
public static HttpClient Client { get; } = new(AuthHandler, true)
{
DefaultRequestHeaders =
public static HttpClient Client { get; } = CreateClient();
public static HttpClient CreateClient(HttpMessageHandler? handler = null) =>
new(handler ?? new SocketsHttpHandler(), true)
{
// Required by some of the services we're using
UserAgent =
DefaultRequestHeaders =
{
new ProductInfoHeaderValue(
"YoutubeDownloader",
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)
)
// Required by some of the services we're using
UserAgent =
{
new ProductInfoHeaderValue(
"YoutubeDownloader",
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)
)
}
}
}
};
};
}

View File

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using YoutubeDownloader.Core.Utils.Extensions;
namespace YoutubeDownloader.Core;
public partial class YoutubeAuthHttpHandler : DelegatingHandler
{
private readonly CookieContainer _cookieContainer = new();
public YoutubeAuthHttpHandler(IReadOnlyDictionary<string, string> cookies)
{
foreach (var (key, value) in cookies)
_cookieContainer.Add(YoutubeDomainUri, new Cookie(key, value));
InnerHandler = new SocketsHttpHandler
{
UseCookies = true,
// Need to use a cookie container because YouTube sets additional cookies
// even after the initial authentication.
CookieContainer = _cookieContainer
};
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var cookies = _cookieContainer
.GetCookies(YoutubeDomainUri)
// Most specific cookies first
.OrderByDescending(c => string.Equals(c.Domain, YoutubeDomain, StringComparison.OrdinalIgnoreCase))
// Discard less specific cookies
.DistinctBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(c => c.Name, c => c.Value, StringComparer.OrdinalIgnoreCase);
var sessionId =
cookies.GetValueOrDefault("__Secure-3PAPISID") ??
cookies.GetValueOrDefault("SAPISID");
if (sessionId is not null)
{
// If only __Secure-3PAPISID is present, add SAPISID manually
if (!cookies.ContainsKey("SAPISID"))
_cookieContainer.Add(YoutubeDomainUri, new Cookie("SAPISID", sessionId));
request.Headers.Remove("Authorization");
request.Headers.Add("Authorization", $"SAPISIDHASH {GenerateAuthHash(sessionId)}");
request.Headers.Remove("Origin");
request.Headers.Add("Origin", YoutubeDomain);
request.Headers.Remove("X-Origin");
request.Headers.Add("X-Origin", YoutubeDomain);
// Set to 0 as it is only allowed to be logged in with one account
request.Headers.Add("X-Goog-AuthUser", "0");
}
return await base.SendAsync(request, cancellationToken);
}
}
public partial class YoutubeAuthHttpHandler
{
private const string YoutubeDomain = "https://www.youtube.com";
private static readonly Uri YoutubeDomainUri = new(YoutubeDomain);
private static string GenerateAuthHash(string sessionId)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000;
var token = $"{timestamp} {sessionId} {YoutubeDomain}";
var tokenHash = SHA1.HashData(Encoding.UTF8.GetBytes(token)).ToHex();
return timestamp + "_" + tokenHash;
}
}

View File

@ -35,12 +35,12 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
}
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{
base.OnUnhandledException(e);
base.OnUnhandledException(args);
MessageBox.Show(
e.Exception.ToString(),
args.Exception.ToString(),
"Error occured",
MessageBoxButton.OK,
MessageBoxImage.Error

View File

@ -21,6 +21,8 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
public bool IsAuthPersisted { get; set; } = true;
public bool ShouldInjectTags { get; set; } = true;
public bool ShouldSkipExistingFiles { get; set; }
@ -28,12 +30,11 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged
public string FileNameTemplate { get; set; } = "$title";
public int ParallelLimit { get; set; } = 2;
public Dictionary<string,string> Cookies { get; set; } = new();
public string? PageId { get; set; }
public Version? LastAppVersion { get; set; }
public IReadOnlyDictionary<string, string>? LastAuthCookies { get; set; }
// STJ cannot properly serialize immutable structs
[JsonConverter(typeof(ContainerJsonConverter))]
public Container LastContainer { get; set; } = Container.Mp4;
@ -44,6 +45,18 @@ public partial class SettingsService : SettingsBase, INotifyPropertyChanged
: base(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
{
}
public override void Save()
{
// Clear the cookies if they are not supposed to be persisted
var lastAuthCookies = LastAuthCookies;
if (!IsAuthPersisted)
LastAuthCookies = null;
base.Save();
LastAuthCookies = lastAuthCookies;
}
}
public partial class SettingsService

View File

@ -1,16 +1,17 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Gress;
using Gress.Completable;
using Stylet;
using YoutubeDownloader.Core;
using YoutubeDownloader.Core.Downloading;
using YoutubeDownloader.Core.Resolving;
using YoutubeDownloader.Core.Tagging;
using YoutubeDownloader.Core.Utils;
using YoutubeDownloader.Core.Utils.Extensions;
using YoutubeDownloader.Services;
using YoutubeDownloader.Utils;
using YoutubeDownloader.ViewModels.Dialogs;
@ -28,10 +29,6 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
private readonly AutoResetProgressMuxer _progressMuxer;
private readonly ResizableSemaphore _downloadSemaphore = new();
private readonly QueryResolver _queryResolver = new();
private readonly VideoDownloader _videoDownloader = new();
private readonly MediaTagInjector _mediaTagInjector = new();
public bool IsBusy { get; private set; }
public ProgressContainer<Percentage> Progress { get; } = new();
@ -55,13 +52,32 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
_settingsService.BindAndInvoke(o => o.ParallelLimit, (_, e) => _downloadSemaphore.MaxCount = e.NewValue);
_settingsService.BindAndInvoke(o => o.Cookies, (_ ,e ) => Http.AuthHandler.SetCookies(e.NewValue.Select(i => $"{i.Key}={i.Value}").Join(",")));
_settingsService.BindAndInvoke(o => o.PageId, (_, e) => Http.AuthHandler.PageId = e.NewValue);
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Downloads.Bind(o => o.Count, (_, _) => NotifyOfPropertyChange(() => IsDownloadsAvailable));
_settingsService.BindAndInvoke(
o => o.ParallelLimit,
(_, e) => _downloadSemaphore.MaxCount = e.NewValue
);
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
Downloads.Bind(
o => o.Count,
(_, _) => NotifyOfPropertyChange(() => IsDownloadsAvailable)
);
}
private HttpClient CreateHttpClient() => Http.CreateClient(
new YoutubeAuthHttpHandler(_settingsService.LastAuthCookies ?? new Dictionary<string, string>())
);
public bool CanShowAuthSetup => !IsBusy;
public async void ShowAuthSetup() => await _dialogManager.ShowDialogAsync(
_viewModelFactory.CreateAuthSetupViewModel()
);
public bool CanShowSettings => !IsBusy;
public async void ShowSettings() => await _dialogManager.ShowDialogAsync(
@ -76,19 +92,22 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
{
try
{
using var http = CreateHttpClient();
var downloader = new VideoDownloader(http);
using var access = await _downloadSemaphore.AcquireAsync(download.CancellationToken);
download.Status = DownloadStatus.Started;
var downloadOption =
download.DownloadOption ??
await _videoDownloader.GetBestDownloadOptionAsync(
await downloader.GetBestDownloadOptionAsync(
download.Video!.Id,
download.DownloadPreference!,
download.CancellationToken
);
await _videoDownloader.DownloadVideoAsync(
await downloader.DownloadVideoAsync(
download.FilePath!,
download.Video!,
downloadOption,
@ -100,7 +119,7 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
{
try
{
await _mediaTagInjector.InjectTagsAsync(
await new MediaTagInjector().InjectTagsAsync(
download.FilePath!,
download.Video!,
download.CancellationToken
@ -159,16 +178,21 @@ public class DashboardViewModel : PropertyChangedBase, IDisposable
try
{
var result = await _queryResolver.ResolveAsync(
using var http = CreateHttpClient();
var resolver = new QueryResolver(http);
var result = await resolver.ResolveAsync(
Query.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
progress
);
var downloader = new VideoDownloader(http);
// Single video
if (result.Videos.Count == 1)
{
var video = result.Videos.Single();
var downloadOptions = await _videoDownloader.GetDownloadOptionsAsync(video.Id);
var downloadOptions = await downloader.GetDownloadOptionsAsync(video.Id);
var download = await _dialogManager.ShowDialogAsync(
_viewModelFactory.CreateDownloadSingleSetupViewModel(video, downloadOptions)

View File

@ -46,7 +46,10 @@ public class DownloadViewModel : PropertyChangedBase, IDisposable
_viewModelFactory = viewModelFactory;
_dialogManager = dialogManager;
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
}
public bool CanCancel => Status is DownloadStatus.Enqueued or DownloadStatus.Started;

View File

@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using Stylet;
using YoutubeDownloader.Services;
using YoutubeDownloader.ViewModels.Framework;
namespace YoutubeDownloader.ViewModels.Dialogs;
public class AuthSetupViewModel : DialogScreen
{
private readonly SettingsService _settingsService;
public IReadOnlyDictionary<string, string>? Cookies
{
get => _settingsService.LastAuthCookies;
set => _settingsService.LastAuthCookies = value;
}
public bool IsAuthenticated => Cookies?.Any() == true;
public AuthSetupViewModel(SettingsService settingsService)
{
_settingsService = settingsService;
_settingsService.BindAndInvoke(
o => o.LastAuthCookies,
(_, _) => NotifyOfPropertyChange(() => Cookies)
);
this.BindAndInvoke(
o => o.Cookies,
(_, _) => NotifyOfPropertyChange(() => IsAuthenticated)
);
}
}

View File

@ -1,24 +0,0 @@
using System.Collections.Generic;
using YoutubeDownloader.Services;
using YoutubeDownloader.ViewModels.Framework;
namespace YoutubeDownloader.ViewModels.Dialogs;
public class BrowserViewModel : DialogScreen
{
private readonly SettingsService _settingsService;
public BrowserViewModel(SettingsService settingsService) => _settingsService = settingsService;
public Dictionary<string, string> Cookies
{
get => _settingsService.Cookies;
set => _settingsService.Cookies = value;
}
public string? PageId
{
get => _settingsService.PageId;
set => _settingsService.PageId = value;
}
}

View File

@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using YoutubeDownloader.Services;
using YoutubeDownloader.ViewModels.Framework;
@ -10,15 +7,6 @@ namespace YoutubeDownloader.ViewModels.Dialogs;
public class SettingsViewModel : DialogScreen
{
private readonly SettingsService _settingsService;
private readonly IViewModelFactory _viewModelFactory;
private readonly DialogManager _dialogManager;
public SettingsViewModel(SettingsService settingsService, IViewModelFactory viewModelFactory, DialogManager dialogManager)
{
_settingsService = settingsService;
_viewModelFactory = viewModelFactory;
_dialogManager = dialogManager;
}
public bool IsAutoUpdateEnabled
{
@ -32,6 +20,12 @@ public class SettingsViewModel : DialogScreen
set => _settingsService.IsDarkModeEnabled = value;
}
public bool IsAuthPersisted
{
get => _settingsService.IsAuthPersisted;
set => _settingsService.IsAuthPersisted = value;
}
public bool ShouldInjectTags
{
get => _settingsService.ShouldInjectTags;
@ -55,22 +49,7 @@ public class SettingsViewModel : DialogScreen
get => _settingsService.ParallelLimit;
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
}
public async Task Login()
{
Close();
await _dialogManager.ShowDialogAsync(
_viewModelFactory.CreateBrowserSettingsViewModel()
);
}
public void Logout()
{
_settingsService.Cookies = new Dictionary<string, string>();
_settingsService.PageId = null;
Refresh();
}
public bool IsLogged => _settingsService.Cookies.Any();
public bool IsNotLogged => !IsLogged;
public SettingsViewModel(SettingsService settingsService) =>
_settingsService = settingsService;
}

View File

@ -16,7 +16,7 @@ public interface IViewModelFactory
MessageBoxViewModel CreateMessageBoxViewModel();
AuthSetupViewModel CreateAuthSetupViewModel();
SettingsViewModel CreateSettingsViewModel();
BrowserViewModel CreateBrowserSettingsViewModel();
}

View File

@ -23,6 +23,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Query and process query button -->
@ -77,11 +78,29 @@
</Grid>
</materialDesign:Card>
<!-- Settings button -->
<!-- Auth button -->
<Button
Grid.Column="1"
Margin="6"
Padding="4"
Command="{s:Action ShowAuthSetup}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Authentication">
<Button.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
</Button.Resources>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="AccountKey" />
</Button>
<!-- Settings button -->
<Button
Grid.Column="2"
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
@ -111,8 +130,8 @@
HorizontalAlignment="Center"
Visibility="{Binding IsDownloadsAvailable, Converter={x:Static converters:BoolToVisibilityConverter.CollapsedOrVisible}}">
<materialDesign:PackIcon
Width="258"
Height="258"
Width="256"
Height="256"
HorizontalAlignment="Center"
Foreground="{DynamicResource MaterialDesignDivider}"
Kind="Youtube" />

View File

@ -0,0 +1,99 @@
<UserControl
x:Class="YoutubeDownloader.Views.Dialogs.AuthSetupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:YoutubeDownloader.ViewModels.Dialogs"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Width="450"
Height="450"
d:DataContext="{d:DesignInstance Type=dialogs:AuthSetupViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Title -->
<TextBlock
Grid.Row="0"
Margin="16"
FontSize="19"
FontWeight="Light"
Text="Authentication" />
<!-- Content -->
<Border
Grid.Row="1"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1">
<Grid>
<!-- Current auth info & logout -->
<StackPanel
Margin="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding IsAuthenticated, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<materialDesign:PackIcon
Width="196"
Height="196"
HorizontalAlignment="Center"
Foreground="{DynamicResource MaterialDesignDivider}"
Kind="AccountCheck" />
<TextBlock
HorizontalAlignment="Center"
FontSize="18"
TextAlignment="Center"
TextWrapping="Wrap">
<Run FontWeight="Light" Text="You are currently authenticated" />
<LineBreak />
<Hyperlink x:Name="LogoutHyperlink" Click="LogoutHyperlink_OnClick">
Log out
</Hyperlink>
</TextBlock>
</StackPanel>
<!-- Placeholder -->
<TextBlock
Margin="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="18"
Text="Loading..."
Visibility="{Binding IsAuthenticated, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}" />
<!-- Browser -->
<wpf:WebView2
x:Name="WebBrowser"
CoreWebView2InitializationCompleted="WebBrowser_OnCoreWebView2InitializationCompleted"
Loaded="WebBrowser_OnLoaded"
NavigationCompleted="WebBrowser_OnNavigationCompleted"
NavigationStarting="WebBrowser_OnNavigationStarting"
Unloaded="WebBrowser_OnUnloaded"
Visibility="{Binding IsAuthenticated, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<wpf:WebView2.CreationProperties>
<wpf:CoreWebView2CreationProperties IsInPrivateModeEnabled="True" />
</wpf:WebView2.CreationProperties>
</wpf:WebView2>
</Grid>
</Border>
<!-- Close button -->
<Button
Grid.Row="2"
Margin="16"
HorizontalAlignment="Stretch"
Command="{s:Action Close}"
Content="CLOSE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
</Grid>
</UserControl>

View File

@ -0,0 +1,75 @@
using System;
using System.Linq;
using System.Net;
using System.Windows;
using Microsoft.Web.WebView2.Core;
using YoutubeDownloader.ViewModels.Dialogs;
namespace YoutubeDownloader.Views.Dialogs;
public partial class AuthSetupView
{
private const string HomePageUrl = "https://www.youtube.com";
private static readonly string LoginPageUrl =
$"https://accounts.google.com/ServiceLogin?continue={WebUtility.UrlEncode(HomePageUrl)}";
private AuthSetupViewModel ViewModel => (AuthSetupViewModel) DataContext;
public AuthSetupView()
{
InitializeComponent();
}
private void NavigateToLoginPage() => WebBrowser.Source = new Uri(LoginPageUrl);
private void LogoutHyperlink_OnClick(object sender, RoutedEventArgs args)
{
ViewModel.Cookies = null;
NavigateToLoginPage();
}
private void WebBrowser_OnLoaded(object sender, RoutedEventArgs args) => NavigateToLoginPage();
private void WebBrowser_OnCoreWebView2InitializationCompleted(
object? sender,
CoreWebView2InitializationCompletedEventArgs args)
{
if (!args.IsSuccess)
return;
WebBrowser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
WebBrowser.CoreWebView2.Settings.AreDevToolsEnabled = false;
WebBrowser.CoreWebView2.Settings.IsGeneralAutofillEnabled = false;
WebBrowser.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false;
WebBrowser.CoreWebView2.Settings.IsStatusBarEnabled = false;
WebBrowser.CoreWebView2.Settings.IsSwipeNavigationEnabled = false;
}
private void WebBrowser_OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args)
{
// Reset existing browser cookies if the user is attempting to log in (again)
if (string.Equals(args.Uri, LoginPageUrl, StringComparison.OrdinalIgnoreCase))
WebBrowser.CoreWebView2.CookieManager.DeleteAllCookies();
}
private async void WebBrowser_OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args)
{
var url = WebBrowser.Source.AbsoluteUri.TrimEnd('/');
// Navigated to the home page (presumably after a successful login)
if (string.Equals(url, HomePageUrl, StringComparison.OrdinalIgnoreCase))
{
// Extract the cookies that the browser received after logging in
var cookies = await WebBrowser.CoreWebView2.CookieManager.GetCookiesAsync("");
ViewModel.Cookies = cookies.ToDictionary(i => i.Name, i => i.Value);
}
}
private async void WebBrowser_OnUnloaded(object sender, RoutedEventArgs args)
{
// This will most likely not work because WebView2 would still be running at this point,
// and there doesn't seem to be any way to shut it down using the .NET API.
if (WebBrowser.CoreWebView2?.Profile is not null)
await WebBrowser.CoreWebView2.Profile.ClearBrowsingDataAsync();
}
}

View File

@ -1,19 +0,0 @@
<UserControl x:Class="YoutubeDownloader.Views.Dialogs.BrowserView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:YoutubeDownloader.ViewModels.Dialogs"
xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
d:DataContext="{d:DesignInstance Type=dialogs:BrowserViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d"
Width="380"
Height="450">
<Grid>
<wpf:WebView2
x:Name="WebBrowser"
Source="https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fwww.youtube.com"
NavigationCompleted="WebBrowser_OnNavigationCompleted"/>
</Grid>
</UserControl>

View File

@ -1,42 +0,0 @@
using System.Linq;
using JsonExtensions;
using Microsoft.Web.WebView2.Core;
using YoutubeDownloader.Core.Utils;
using YoutubeDownloader.ViewModels.Dialogs;
namespace YoutubeDownloader.Views.Dialogs;
public partial class BrowserView
{
public BrowserView()
{
InitializeComponent();
}
private BrowserViewModel ViewModel => (BrowserViewModel) DataContext;
private async void WebBrowser_OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
var cookies = await WebBrowser.CoreWebView2.CookieManager.GetCookiesAsync("https://www.youtube.com");
var isOnYoutube = WebBrowser.Source.AbsoluteUri == "https://www.youtube.com/";
if (cookies.Any() && isOnYoutube)
{
var cookiesDic = cookies!.ToDictionary(i => i.Name, i => i.Value);
ViewModel.Cookies = cookiesDic;
var result = await Http.Client.GetStringAsync("https://www.youtube.com/getDatasyncIdsEndpoint");
var datasyncIds = string.Join("\n",result.Split("\n").Skip(1));
var dataSyncIdJson = Json.TryParse(datasyncIds);
var dataSyncId = dataSyncIdJson?.GetProperty("responseContext").GetProperty("mainAppWebResponseContext").GetProperty("datasyncId").GetString()?.Split("||");
var isRequired = dataSyncId?.Length >= 2 && !string.IsNullOrWhiteSpace(dataSyncId?[0]) && !string.IsNullOrWhiteSpace(dataSyncId[1]);
var id = dataSyncId?[0];
if (isRequired && id is not null) ViewModel.PageId = id;
await WebBrowser.CoreWebView2.Profile.ClearBrowsingDataAsync();
WebBrowser.Dispose();
ViewModel.Close();
}
}
}

View File

@ -2,9 +2,9 @@
x:Class="YoutubeDownloader.Views.Dialogs.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:YoutubeDownloader.ViewModels.Dialogs"
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
Width="380"
@ -20,7 +20,7 @@
<TextBlock
Grid.Row="0"
Margin="16,16,16,8"
Margin="16"
FontSize="19"
FontWeight="Light"
Text="Settings" />
@ -68,10 +68,27 @@
x:Name="DarkModeToggleButton"
Margin="16,8"
VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked"
Checked="DarkModeToggleButton_OnChecked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
Unchecked="DarkModeToggleButton_OnUnchecked" />
</DockPanel>
<!-- Persist authentication -->
<DockPanel
Background="Transparent"
LastChildFill="False"
ToolTip="Save authentication cookies to a file so that they can be persisted between sessions">
<TextBlock
Margin="16,8"
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Persist authentication" />
<ToggleButton
Margin="16,8"
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsAuthPersisted}" />
</DockPanel>
<!-- Inject tags -->
@ -169,37 +186,17 @@
Value="{Binding ParallelLimit}" />
</StackPanel>
</DockPanel>
<Button
Margin="16"
HorizontalAlignment="Stretch"
Command="{s:Action Logout}"
Content="Logout"
IsCancel="True"
IsDefault="True"
Visibility="{Binding IsLogged, Converter={x:Static converters:BoolToVisibilityConverter.VisibleOrCollapsed}}"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
<Button
Margin="16"
HorizontalAlignment="Stretch"
Command="{s:Action Login}"
Content="Login"
IsCancel="True"
IsDefault="True"
Visibility="{Binding IsNotLogged, Converter={x:Static converters:BoolToVisibilityConverter.VisibleOrCollapsed}}"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
</StackPanel>
</ScrollViewer>
</Border>
<!-- Save button -->
<!-- Close button -->
<Button
Grid.Row="2"
Margin="16"
HorizontalAlignment="Stretch"
Command="{s:Action Close}"
Content="SAVE"
Content="CLOSE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />

View File

@ -9,9 +9,9 @@ public partial class SettingsView
InitializeComponent();
}
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs args) =>
private void DarkModeToggleButton_OnChecked(object sender, RoutedEventArgs args) =>
App.SetDarkTheme();
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs args) =>
private void DarkModeToggleButton_OnUnchecked(object sender, RoutedEventArgs args) =>
App.SetLightTheme();
}

View File

@ -2,12 +2,12 @@
x:Class="YoutubeDownloader.Views.RootView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:YoutubeDownloader.ViewModels"
xmlns:converters="clr-namespace:YoutubeDownloader.Converters"
Width="600"
Height="580"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
@ -21,7 +21,6 @@
</Window.TaskbarItemInfo>
<materialDesign:DialogHost
x:Name="DialogHost"
CloseOnClickAway="True"
Loaded="{s:Action OnViewFullyLoaded}"
SnackbarMessageQueue="{Binding Notifications}"
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">