OAuth session persists via DPAPI; sign-in lives in the Start Stream dialog; add xUnit test project (6 passing)

This commit is contained in:
2026-08-06 09:38:35 -07:00
parent 8f0ecd3796
commit 4afe11e718
15 changed files with 432 additions and 67 deletions
+74 -6
View File
@@ -1,13 +1,31 @@
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
public class GoLiveViewModel : ViewModelBase
{
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty;
private string _visibility = "Public";
private bool _isSignedIn;
private string _accountDisplayName = string.Empty;
private string _accountAvatarUrl = string.Empty;
private bool _isBusy;
public GoLiveViewModel(Func<Task<YouTubeChannel?>> signInProvider, YouTubeChannel? account)
{
_signInProvider = signInProvider;
SignInCommand = new RelayCommand(_ => _ = SignInAsync());
StartCommand = new RelayCommand(_ => StartRequested?.Invoke(), _ => IsSignedIn && !IsBusy);
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke(), _ => !IsBusy);
if (account != null)
ApplyAccount(account);
}
public string StreamTitle
{
@@ -29,15 +47,65 @@ public class GoLiveViewModel : ViewModelBase
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public bool IsSignedIn
{
get => _isSignedIn;
private set
{
if (SetProperty(ref _isSignedIn, value))
CommandManager.InvalidateRequerySuggested();
}
}
public string AccountDisplayName
{
get => _accountDisplayName;
private set => SetProperty(ref _accountDisplayName, value);
}
public string AccountAvatarUrl
{
get => _accountAvatarUrl;
private set => SetProperty(ref _accountAvatarUrl, value);
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (SetProperty(ref _isBusy, value))
CommandManager.InvalidateRequerySuggested();
}
}
public ICommand SignInCommand { get; }
public ICommand StartCommand { get; }
public ICommand CancelCommand { get; }
public GoLiveViewModel()
{
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
}
public event Action? StartRequested;
public event Action? CancelRequested;
private async Task SignInAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
var channel = await _signInProvider();
if (channel != null)
ApplyAccount(channel);
}
finally
{
IsBusy = false;
}
}
private void ApplyAccount(YouTubeChannel channel)
{
AccountDisplayName = channel.DisplayName;
AccountAvatarUrl = channel.ProfileImageUrl;
IsSignedIn = true;
}
}
+42 -11
View File
@@ -126,18 +126,14 @@ public class MainViewModel : ViewModelBase
set
{
if (SetProperty(ref _isConnected, value))
{
OnPropertyChanged(nameof(IsNotConnected));
OnPropertyChanged(nameof(ShowStartStream));
}
}
}
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
public bool IsNotConnected => !IsConnected;
public bool ShowStartStream => IsConnected && IsOffline;
public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
@@ -406,7 +402,6 @@ public class MainViewModel : ViewModelBase
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand ConnectCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
@@ -424,7 +419,8 @@ public class MainViewModel : ViewModelBase
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created");
@@ -462,7 +458,6 @@ public class MainViewModel : ViewModelBase
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
ConnectCommand = new RelayCommand(_ => _ = ConnectAsync());
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
@@ -472,9 +467,40 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
LoadLayout();
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end");
}
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
// Best-effort: refresh a near-expiry access token; a session that can no
// longer refresh is discarded and the user signs in again.
private async Task LoadSavedSessionAsync()
{
try
{
var saved = TokenStore.Load();
if (saved == null) return;
_youtubeAuth.SetSession(saved);
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
{
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
if (!refreshed)
{
TokenStore.Clear();
AppLog.Write("Saved session could not be refreshed; signed out");
return;
}
}
IsConnected = true;
}
catch (Exception ex)
{
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
}
}
private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
@@ -870,7 +896,10 @@ public class MainViewModel : ViewModelBase
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
private async Task ConnectAsync()
// Runs the OAuth flow (browser + loopback callback) on a background thread.
// The resulting channel is returned to the caller and persisted via the
// auth service's sessionChanged hook.
private async Task<YouTubeChannel?> SignInAsync()
{
try
{
@@ -879,15 +908,17 @@ public class MainViewModel : ViewModelBase
{
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
return null;
}
IsConnected = true;
return channel;
}
catch (Exception ex)
{
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}
}
@@ -903,7 +934,7 @@ public class MainViewModel : ViewModelBase
private void BeginGoLive()
{
var dialog = new GoLiveViewModel
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = DefaultStreamTitle,
StreamDescription = DefaultStreamDescription,
+2 -2
View File
@@ -4,8 +4,8 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`) |
| `GoLiveViewModel.cs` | Start Stream dialog: title/description/visibility, start/cancel requests |
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)