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
+64 -3
View File
@@ -1,7 +1,7 @@
<Window x:Class="ytLive.GoLiveWindow" <Window x:Class="ytLive.GoLiveWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Start Stream" Height="430" Width="520" Title="Start Stream" Height="480" Width="520"
Icon="/Assets/llama-logo-icon.png" Icon="/Assets/llama-logo-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e"> ShowInTaskbar="False" Background="#1a1a2e">
@@ -14,6 +14,7 @@
<Grid Margin="24"> <Grid Margin="24">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
@@ -23,8 +24,68 @@
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold" <TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,16"/> Foreground="#e0e0e0" Margin="0,0,0,16"/>
<!-- ACCOUNT -->
<StackPanel Grid.Row="1" Margin="0,0,0,16">
<TextBlock Text="ACCOUNT" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
<!-- signed in -->
<StackPanel Orientation="Horizontal">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSignedIn}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Border Width="34" Height="34" CornerRadius="17" Background="#16213e" ClipToBounds="True"
VerticalAlignment="Center" Margin="0,0,10,0">
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
</Border>
<TextBlock Text="{Binding AccountDisplayName}" Foreground="#e0e0e0" FontSize="14"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Content="Change Account" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
</StackPanel>
<!-- signed out -->
<StackPanel Orientation="Horizontal">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSignedIn}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,10,0"/>
<TextBlock Text="Going live requires a YouTube account." Foreground="#a0a0b0" FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Signing in… open the browser to authorize, then come back here."
Foreground="#e94560" FontSize="12" Margin="0,8,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsBusy}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- STREAM METADATA --> <!-- STREAM METADATA -->
<StackPanel Grid.Row="1"> <StackPanel Grid.Row="2">
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold" <TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/> Foreground="#a0a0b0" Margin="0,0,0,8"/>
@@ -44,7 +105,7 @@
</StackPanel> </StackPanel>
<!-- ACTIONS --> <!-- ACTIONS -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Command="{Binding CancelCommand}" <Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/> Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Start Stream" Command="{Binding StartCommand}" <Button Content="Start Stream" Command="{Binding StartCommand}"
+55
View File
@@ -0,0 +1,55 @@
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using ytLive.Models;
namespace ytLive.Helpers;
/// <summary>
/// Persists the OAuth session (channel + tokens) to a DPAPI-protected file in
/// %APPDATA%\ytLlive\ytLlive.auth so sign-in survives restarts. The whole
/// payload is protected with DataProtectionScope.CurrentUser — only the
/// signed-in Windows user can decrypt it.
/// </summary>
public static class TokenStore
{
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive",
"ytLlive.auth");
public static void Save(YouTubeChannel channel, string? path = null)
{
var json = JsonSerializer.Serialize(channel);
var plain = System.Text.Encoding.UTF8.GetBytes(json);
var protectedBytes = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
var file = path ?? DefaultPath;
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllBytes(file, protectedBytes);
}
public static YouTubeChannel? Load(string? path = null)
{
var file = path ?? DefaultPath;
if (!File.Exists(file)) return null;
try
{
var protectedBytes = File.ReadAllBytes(file);
var plain = ProtectedData.Unprotect(protectedBytes, null, DataProtectionScope.CurrentUser);
var json = System.Text.Encoding.UTF8.GetString(plain);
return JsonSerializer.Deserialize<YouTubeChannel>(json);
}
catch
{
return null;
}
}
public static void Clear(string? path = null)
{
var file = path ?? DefaultPath;
if (File.Exists(file)) File.Delete(file);
}
}
+1
View File
@@ -9,6 +9,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for) | | `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for) |
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard | | `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) | | `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
| `ImageCache.cs` | Image byte caching (assets live in the DB) | | `ImageCache.cs` | Image byte caching (assets live in the DB) |
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings | | `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
-31
View File
@@ -31,29 +31,6 @@
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis" <Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/> xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<!-- YouTube logo shown on the Connect button -->
<DrawingImage x:Key="YoutubeLogo">
<DrawingImage.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#FF0000">
<GeometryDrawing.Geometry>
<RectangleGeometry Rect="0,0,66,47" RadiusX="10" RadiusY="10"/>
</GeometryDrawing.Geometry>
</GeometryDrawing>
<GeometryDrawing Brush="White">
<GeometryDrawing.Geometry>
<PathGeometry>
<PathFigure IsClosed="True" StartPoint="25,14">
<LineSegment Point="25,33"/>
<LineSegment Point="45,23.5"/>
</PathFigure>
</PathGeometry>
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingGroup>
</DrawingImage.Drawing>
</DrawingImage>
<!-- Red dot shown in the taskbar icon while live --> <!-- Red dot shown in the taskbar icon while live -->
<DrawingImage x:Key="LiveOverlay"> <DrawingImage x:Key="LiveOverlay">
<DrawingImage.Drawing> <DrawingImage.Drawing>
@@ -123,14 +100,6 @@
<!-- Single three-state action button --> <!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource YtButtonSecondary}" Command="{Binding ConnectCommand}"
Visibility="{Binding IsNotConnected, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Image Source="{StaticResource YoutubeLogo}" Width="22" Height="16"
VerticalAlignment="Center"/>
<TextBlock Text="Connect" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Content="Start Stream" Style="{StaticResource YtButton}" <Button Content="Start Stream" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}" Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
+14 -2
View File
@@ -19,14 +19,24 @@ public class YouTubeAuthService
private readonly string _clientId; private readonly string _clientId;
private readonly string _clientSecret; private readonly string _clientSecret;
private readonly HttpClient _http = new(); private readonly HttpClient _http;
private readonly Action<YouTubeChannel>? _sessionChanged;
public YouTubeChannel? CurrentChannel { get; private set; } public YouTubeChannel? CurrentChannel { get; private set; }
public YouTubeAuthService(string clientId, string clientSecret) public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
Action<YouTubeChannel>? sessionChanged = null)
{ {
_clientId = clientId; _clientId = clientId;
_clientSecret = clientSecret; _clientSecret = clientSecret;
_http = http ?? new HttpClient();
_sessionChanged = sessionChanged;
}
/// <summary>Replaces the current session (used when a saved token loads at startup).</summary>
public void SetSession(YouTubeChannel channel)
{
CurrentChannel = channel;
} }
public string GetAuthorizationUrl(string redirectUri) public string GetAuthorizationUrl(string redirectUri)
@@ -136,6 +146,7 @@ public class YouTubeAuthService
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!; CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32()); CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
_sessionChanged?.Invoke(CurrentChannel);
return true; return true;
} }
@@ -162,6 +173,7 @@ public class YouTubeAuthService
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn) TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
}; };
_sessionChanged?.Invoke(CurrentChannel);
return CurrentChannel; return CurrentChannel;
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ External-facing logic: YouTube API, persistence. See
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. **Complete — but no token persistence yet** (DPAPI planned) | | `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** | | `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` | | `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files | | `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files |
+4 -3
View File
@@ -72,11 +72,12 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
- Verify token refresh triggers when near expiry - Verify token refresh triggers when near expiry
- Verify credential load/save roundtrip - Verify credential load/save roundtrip
### Status: 🔶 UI flow done — auth wiring pending ### Status: ✅ Complete
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot - ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
- ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch - ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
- Token persistence via Windows DPAPI, reload on startup - Token persistence via Windows DPAPI (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
- Account sign-in/change surfaced in the GoLive dialog (GoLiveViewModel still simulates it) - Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
- ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump — 6 passing
--- ---
+74 -6
View File
@@ -1,13 +1,31 @@
using System.Windows.Input; using System.Windows.Input;
using ytLive.Helpers; using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
public class GoLiveViewModel : ViewModelBase public class GoLiveViewModel : ViewModelBase
{ {
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
private string _streamTitle = string.Empty; private string _streamTitle = string.Empty;
private string _streamDescription = string.Empty; private string _streamDescription = string.Empty;
private string _visibility = "Public"; 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 public string StreamTitle
{ {
@@ -29,15 +47,65 @@ public class GoLiveViewModel : ViewModelBase
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" }; 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 StartCommand { get; }
public ICommand CancelCommand { get; } public ICommand CancelCommand { get; }
public GoLiveViewModel()
{
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
}
public event Action? StartRequested; public event Action? StartRequested;
public event Action? CancelRequested; 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 set
{ {
if (SetProperty(ref _isConnected, value)) if (SetProperty(ref _isConnected, value))
{
OnPropertyChanged(nameof(IsNotConnected));
OnPropertyChanged(nameof(ShowStartStream)); OnPropertyChanged(nameof(ShowStartStream));
}
} }
} }
public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive; public bool LiveIndicatorVisible => IsLive;
public bool IsNotConnected => !IsConnected; public bool ShowStartStream => IsOffline;
public bool ShowStartStream => IsConnected && IsOffline;
public bool ShowChatInactiveMessage => !IsLive; public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0; public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
@@ -406,7 +402,6 @@ public class MainViewModel : ViewModelBase
public ICommand AddSourceCommand { get; } public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; } public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; } public ICommand RemoveSourceCommand { get; }
public ICommand ConnectCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; } public ICommand OpenSettingsCommand { get; }
@@ -424,7 +419,8 @@ public class MainViewModel : ViewModelBase
public MainViewModel() public MainViewModel()
{ {
AppLog.Write("MainViewModel ctor begin"); 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); _youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth); _youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created"); AppLog.Write("MainViewModel ctor: services created");
@@ -462,7 +458,6 @@ public class MainViewModel : ViewModelBase
SubmitBugCommand = new RelayCommand(_ => SubmitBug()); SubmitBugCommand = new RelayCommand(_ => SubmitBug());
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature()); SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl)); OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
ConnectCommand = new RelayCommand(_ => _ = ConnectAsync());
StartStreamCommand = new RelayCommand(_ => BeginGoLive()); StartStreamCommand = new RelayCommand(_ => BeginGoLive());
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive); EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow()); SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
@@ -472,9 +467,40 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(DefaultLayoutPath); _layoutStore = new LayoutStore(DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath; _activeLayoutPath = _layoutStore.ActivePath;
LoadLayout(); LoadLayout();
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end"); 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( private static string DefaultLayoutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ytLlive", "ytLlive",
@@ -870,7 +896,10 @@ public class MainViewModel : ViewModelBase
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); 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 try
{ {
@@ -879,15 +908,17 @@ public class MainViewModel : ViewModelBase
{ {
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive", MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Warning); MessageBoxButton.OK, MessageBoxImage.Warning);
return; return null;
} }
IsConnected = true; IsConnected = true;
return channel;
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive", MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
MessageBoxButton.OK, MessageBoxImage.Error); MessageBoxButton.OK, MessageBoxImage.Error);
return null;
} }
} }
@@ -903,7 +934,7 @@ public class MainViewModel : ViewModelBase
private void BeginGoLive() private void BeginGoLive()
{ {
var dialog = new GoLiveViewModel var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{ {
StreamTitle = DefaultStreamTitle, StreamTitle = DefaultStreamTitle,
StreamDescription = DefaultStreamDescription, StreamDescription = DefaultStreamDescription,
+2 -2
View File
@@ -4,8 +4,8 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose | | 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`) | | `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: title/description/visibility, start/cancel requests | | `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 | | `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) Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
+10 -8
View File
@@ -47,7 +47,7 @@ C# / WPF (.NET 8) following MVVM:
| `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage | | `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel | | `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) | | `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, visibility converters | | `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) | | `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
@@ -64,9 +64,8 @@ C# / WPF (.NET 8) following MVVM:
### Current limitations / TODOs ### Current limitations / TODOs
- `Helpers/OAuthCredentials.cs` now contains the real ClientId/ClientSecret — auth service is implemented, but **tokens still don't persist** (Windows DPAPI planned; account UI in the GoLive dialog is simulated) - `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- `GoLiveViewModel.SignIn`/`ChangeAccount` removed — Connect (OAuth) is the only entry to streaming - Scene/source/asset layout persists (SQLite); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- Scene/source/asset layout *does* persist (SQLite); token persistence does not (yet)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream - `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- No capture/encoding/RTMP yet - No capture/encoding/RTMP yet
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
@@ -106,16 +105,19 @@ resolution & streaming-constraints conversation, not monetization.
The app is fully usable without authentication: users can build scenes, add sources, compose The app is fully usable without authentication: users can build scenes, add sources, compose
previews, and audition the software with zero commitment. But **going live requires authentication** previews, and audition the software with zero commitment. But **going live requires authentication**
it's the one capability gated behind YouTube sign-in. The sign-in button should never pressure the it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected. ("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) /
**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as
the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and
the Start button stays disabled until signed in.
## Account assumption (do not build an account setup flow) ## Account assumption (do not build an account setup flow)
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
replaced by an in-app account-creation step. Zero state = a Connect button that starts OAuth; going replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
live is unreachable until the account is connected. YouTube" button; going live is unreachable until an account is connected.
## YouTube Live API — design constraints (do not violate) ## YouTube Live API — design constraints (do not violate)
+62
View File
@@ -0,0 +1,62 @@
using System.IO;
using Xunit;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Tests;
public class TokenStoreTests
{
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), "ytLive_tests", $"{Guid.NewGuid():N}.auth");
[Fact]
public void Save_Load_RoundtripsChannelAndTokens()
{
var path = TempPath();
var channel = new YouTubeChannel
{
ChannelId = "UC123",
DisplayName = "Test Channel",
ProfileImageUrl = "http://example/thumb.jpg",
AccessToken = "acc-token",
RefreshToken = "refresh-token",
TokenExpiry = DateTime.UtcNow.AddHours(1),
};
TokenStore.Save(channel, path);
var loaded = TokenStore.Load(path);
Assert.NotNull(loaded);
Assert.Equal("UC123", loaded!.ChannelId);
Assert.Equal("Test Channel", loaded.DisplayName);
Assert.Equal("http://example/thumb.jpg", loaded.ProfileImageUrl);
Assert.Equal("acc-token", loaded.AccessToken);
Assert.Equal("refresh-token", loaded.RefreshToken);
Assert.Equal(channel.TokenExpiry, loaded.TokenExpiry);
}
[Fact]
public void Load_MissingFile_ReturnsNull()
{
Assert.Null(TokenStore.Load(TempPath()));
}
[Fact]
public void Load_CorruptFile_ReturnsNull()
{
var path = TempPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, [1, 2, 3, 4, 5]);
Assert.Null(TokenStore.Load(path));
}
[Fact]
public void Clear_RemovesSavedToken()
{
var path = TempPath();
TokenStore.Save(new YouTubeChannel { DisplayName = "X" }, path);
TokenStore.Clear(path);
Assert.Null(TokenStore.Load(path));
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Net;
using System.Text;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
public class YouTubeAuthServiceTests
{
private sealed class FakeHandler : HttpMessageHandler
{
private readonly string _tokenResponse;
private readonly string _channelResponse;
public FakeHandler(string tokenResponse, string channelResponse)
{
_tokenResponse = tokenResponse;
_channelResponse = channelResponse;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var isToken = request.RequestUri!.PathAndQuery.Contains("/token");
var body = isToken ? _tokenResponse : _channelResponse;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
}
}
private static YouTubeAuthService CreateService(string tokenResponse, string channelResponse) =>
new("test-id", "test-secret", new HttpClient(new FakeHandler(tokenResponse, channelResponse)));
[Fact]
public async Task ExchangeCodeForToken_MockServer_ParsesChannel()
{
var service = CreateService(
"""{"access_token":"acc-123","refresh_token":"ref-123","expires_in":3600}""",
"""{"items":[{"id":"UC123","snippet":{"title":"Test Channel","thumbnails":{"default":{"url":"http://example/thumb.jpg"}}}}]}""");
var channel = await service.ExchangeCodeForToken("auth-code", "http://localhost:8765/oauth2/callback");
Assert.NotNull(channel);
Assert.Equal("UC123", channel!.ChannelId);
Assert.Equal("Test Channel", channel.DisplayName);
Assert.Equal("http://example/thumb.jpg", channel.ProfileImageUrl);
Assert.Equal("acc-123", channel.AccessToken);
Assert.Equal("ref-123", channel.RefreshToken);
Assert.InRange(channel.TokenExpiry, DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610));
}
[Fact]
public async Task RefreshToken_MockServer_UpdatesAccessTokenAndExpiry()
{
var service = CreateService(
"""{"access_token":"new-acc","expires_in":3600}""",
"""{"items":[]}""");
service.SetSession(new YouTubeChannel
{
RefreshToken = "ref-123",
TokenExpiry = DateTime.UtcNow.AddMinutes(-5),
});
var ok = await service.RefreshToken();
Assert.True(ok);
Assert.Equal("new-acc", service.CurrentChannel!.AccessToken);
Assert.InRange(service.CurrentChannel.TokenExpiry,
DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610));
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1"/>
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ytLive.csproj"/>
</ItemGroup>
</Project>
+8
View File
@@ -12,6 +12,13 @@
<RootNamespace>ytLive</RootNamespace> <RootNamespace>ytLive</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Compile Remove="ytLive.Tests\**"/>
<EmbeddedResource Remove="ytLive.Tests\**"/>
<None Remove="ytLive.Tests\**"/>
<Page Remove="ytLive.Tests\**"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Resource Include="Assets\llama-logo.png"/> <Resource Include="Assets\llama-logo.png"/>
<Resource Include="Assets\llama-logo-icon.png"/> <Resource Include="Assets\llama-logo-icon.png"/>
@@ -20,6 +27,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
</ItemGroup> </ItemGroup>
</Project> </Project>