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"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
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"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
@@ -14,6 +14,7 @@
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@@ -23,8 +24,68 @@
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
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 -->
<StackPanel Grid.Row="1">
<StackPanel Grid.Row="2">
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
@@ -44,7 +105,7 @@
</StackPanel>
<!-- ACTIONS -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<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) |
| `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) |
| `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) |
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
-31
View File
@@ -31,29 +31,6 @@
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
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 -->
<DrawingImage x:Key="LiveOverlay">
<DrawingImage.Drawing>
@@ -123,14 +100,6 @@
<!-- Single three-state action button -->
<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}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
+14 -2
View File
@@ -19,14 +19,24 @@ public class YouTubeAuthService
private readonly string _clientId;
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 YouTubeAuthService(string clientId, string clientSecret)
public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
Action<YouTubeChannel>? sessionChanged = null)
{
_clientId = clientId;
_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)
@@ -136,6 +146,7 @@ public class YouTubeAuthService
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
_sessionChanged?.Invoke(CurrentChannel);
return true;
}
@@ -162,6 +173,7 @@ public class YouTubeAuthService
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
};
_sessionChanged?.Invoke(CurrentChannel);
return CurrentChannel;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ External-facing logic: YouTube API, persistence. See
| 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** |
| `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 |
+4 -3
View File
@@ -72,11 +72,12 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
- Verify token refresh triggers when near expiry
- 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
- ✅ 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
- Account sign-in/change surfaced in the GoLive dialog (GoLiveViewModel still simulates it)
- 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 (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 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)
+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 |
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
| `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`) |
| `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
- `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)
- `GoLiveViewModel.SignIn`/`ChangeAccount` removed — Connect (OAuth) is the only entry to streaming
- Scene/source/asset layout *does* persist (SQLite); token persistence does not (yet)
- `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`)
- Scene/source/asset layout persists (SQLite); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- No capture/encoding/RTMP yet
- `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
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
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected.
it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user
("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)
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
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
live is unreachable until the account is connected.
replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
YouTube" button; going live is unreachable until an account is connected.
## 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>
</PropertyGroup>
<ItemGroup>
<Compile Remove="ytLive.Tests\**"/>
<EmbeddedResource Remove="ytLive.Tests\**"/>
<None Remove="ytLive.Tests\**"/>
<Page Remove="ytLive.Tests\**"/>
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\llama-logo.png"/>
<Resource Include="Assets\llama-logo-icon.png"/>
@@ -20,6 +27,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
</ItemGroup>
</Project>