OAuth session persists via DPAPI; sign-in lives in the Start Stream dialog; add xUnit test project (6 passing)
This commit is contained in:
+64
-3
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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}}"/>
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user