diff --git a/App.axaml.cs b/App.axaml.cs index b454913..9f2bbbe 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -8,88 +8,97 @@ using Avalonia.Markup.Xaml; using BetterRaid.ViewModels; using BetterRaid.Views; using TwitchLib.Api; -using TwitchLib.Client; -using TwitchLib.Client.Events; -using TwitchLib.Client.Models; -using TwitchLib.Communication.Clients; -using TwitchLib.Communication.Models; namespace BetterRaid; public partial class App : Application { - public static int AutoUpdateDelay = 10_000; - public static string TwitchBroadcasterId = ""; - public static string TwitchChannelName = ""; - public static string TokenClientId = ""; - public static string TokenClientSecret = ""; - public static string TokenClientAccess = ""; - public static TwitchClient? TwitchClient = null; - public static TwitchAPI? TwitchAPI = null; + internal static TwitchAPI? TwitchApi = null; + internal static int AutoUpdateDelay = 10_000; + internal static string TwitchOAuthAccessToken = ""; + internal static string TwitchOAuthAccessTokenFilePath = ""; + internal static string TokenClientId = "kkxu4jorjrrc5jch1ito5i61hbev2o"; + internal static readonly string TwitchOAuthRedirectUrl = "http://localhost:9900"; + internal static readonly string TwitchOAuthResponseType = "token"; + internal static readonly string[] TwitchOAuthScopes = [ "channel:manage:raids", "user:read:chat" ]; + internal static readonly string TwitchOAuthUrl = $"https://id.twitch.tv/oauth2/authorize" + + $"?client_id={TokenClientId}" + + "&redirect_uri=http://localhost:9900" + + $"&response_type={TwitchOAuthResponseType}" + + $"&scope={string.Join("+", TwitchOAuthScopes)}"; public override void Initialize() { - try + var userHomeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var betterRaidDir = ""; + + switch (Environment.OSVersion.Platform) { - var tokenFile = "zn_twitch.secret"; - var profilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var tokenFilePath = Path.Combine(profilePath, tokenFile); - var tokenFileLines = File.ReadAllLines(tokenFilePath); - TwitchChannelName = tokenFileLines[0].Split('=')[1]; - TokenClientId = tokenFileLines[1].Split('=')[1]; - TokenClientSecret = tokenFileLines[2].Split('=')[1]; - TokenClientAccess = tokenFileLines[3].Split('=')[1]; - } - catch (Exception) - { - Console.WriteLine("[ERROR] Failed to read token from secret file!"); - Environment.Exit(1); + case PlatformID.Win32NT: + betterRaidDir = Path.Combine(userHomeDir, "AppData", "Roaming", "BetterRaid"); + break; + case PlatformID.Unix: + betterRaidDir = Path.Combine(userHomeDir, ".config", "BetterRaid"); + break; + case PlatformID.MacOSX: + betterRaidDir = Path.Combine(userHomeDir, "Library", "Application Support", "BetterRaid"); + break; } - var creds = new ConnectionCredentials(TwitchChannelName, TokenClientAccess); - var clientOptions = new ClientOptions + if (!Directory.Exists(betterRaidDir)) + Directory.CreateDirectory(betterRaidDir); + + TwitchOAuthAccessTokenFilePath = Path.Combine(betterRaidDir, ".access_token"); + + if (File.Exists(TwitchOAuthAccessTokenFilePath)) { - MessagesAllowedInPeriod = 750, - ThrottlingPeriod = TimeSpan.FromSeconds(30) - }; - - var customClient = new WebSocketClient(clientOptions); - TwitchClient = new TwitchClient(customClient); - - TwitchClient.Initialize(creds, TwitchChannelName); - TwitchClient.OnConnected += OnConnected; - TwitchClient.OnConnectionError += OnConnectionError; - - TwitchClient.Connect(); - - TwitchAPI = new TwitchAPI(); - TwitchAPI.Settings.ClientId = TokenClientId; - TwitchAPI.Settings.AccessToken = TokenClientAccess; - - var channels = TwitchAPI.Helix.Search.SearchChannelsAsync(TwitchChannelName).Result; - var exactChannel = channels.Channels.FirstOrDefault(c => c.BroadcasterLogin == TwitchChannelName); - - if (exactChannel != null) - { - TwitchBroadcasterId = exactChannel.Id; - Console.WriteLine($"TWITCH BROADCASTER ID SET TO {TwitchBroadcasterId}"); - } - else - { - Console.WriteLine("FAILED TO SET BROADCASTER ID!"); + TwitchOAuthAccessToken = File.ReadAllText(TwitchOAuthAccessTokenFilePath); + InitTwitchClient(); } + AvaloniaXamlLoader.Load(this); } - private void OnConnectionError(object? sender, OnConnectionErrorArgs e) + public static void InitTwitchClient(bool overrideToken = false) { - Console.WriteLine("[ERROR] Twitch Client failed to connect!"); - } + Console.WriteLine("[INFO] Initializing Twitch Client..."); - private void OnConnected(object? sender, OnConnectedArgs e) - { - Console.WriteLine("[INFO] Twitch Client connected!"); + TwitchApi = new TwitchAPI(); + TwitchApi.Settings.ClientId = TokenClientId; + TwitchApi.Settings.AccessToken = TwitchOAuthAccessToken; + + Console.WriteLine("[INFO] Testing Twitch API connection..."); + + var user = TwitchApi.Helix.Users.GetUsersAsync().Result.Users.FirstOrDefault(); + if (user == null) + { + TwitchApi = null; + Console.WriteLine("[ERROR] Failed to connect to Twitch API!"); + return; + } + + Console.WriteLine("[INFO] Connected to Twitch API as '{0}'!", user.DisplayName); + + if (overrideToken) + { + File.WriteAllText(TwitchOAuthAccessTokenFilePath, TwitchOAuthAccessToken); + + switch (Environment.OSVersion.Platform) + { + case PlatformID.Win32NT: + File.SetAttributes(TwitchOAuthAccessTokenFilePath, File.GetAttributes(TwitchOAuthAccessTokenFilePath) | FileAttributes.Hidden); + break; + case PlatformID.Unix: +#pragma warning disable CA1416 // Validate platform compatibility + File.SetUnixFileMode(TwitchOAuthAccessTokenFilePath, UnixFileMode.UserRead); +#pragma warning restore CA1416 // Validate platform compatibility + break; + case PlatformID.MacOSX: + File.SetAttributes(TwitchOAuthAccessTokenFilePath, File.GetAttributes(TwitchOAuthAccessTokenFilePath) | FileAttributes.Hidden); + break; + } + } } public override void OnFrameworkInitializationCompleted() diff --git a/Assets/glitch_flat_white.png b/Assets/glitch_flat_white.png new file mode 100644 index 0000000..b133bc3 Binary files /dev/null and b/Assets/glitch_flat_white.png differ diff --git a/BetterRaid.generated.sln b/BetterRaid.generated.sln index 9354cb2..e4ffe1e 100644 --- a/BetterRaid.generated.sln +++ b/BetterRaid.generated.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.002.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{6BE742A5-079D-4617-BDC1-2933274CDCB7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{EDFD12AB-9E05-4D87-9139-C220A703CFDB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,10 +11,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6BE742A5-079D-4617-BDC1-2933274CDCB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6BE742A5-079D-4617-BDC1-2933274CDCB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6BE742A5-079D-4617-BDC1-2933274CDCB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6BE742A5-079D-4617-BDC1-2933274CDCB7}.Release|Any CPU.Build.0 = Release|Any CPU + {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Misc/Tools.cs b/Misc/Tools.cs new file mode 100644 index 0000000..b7cf484 --- /dev/null +++ b/Misc/Tools.cs @@ -0,0 +1,170 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace BetterRaid.Misc; + +public static class Tools +{ + private static HttpListener? _oauthListener; + private static Task? _oauthWaiterTask; + + // Source: https://stackoverflow.com/a/43232486 + public static void StartOAuthLogin(string url, Action? callback = null, CancellationToken? token = null) + { + if (_oauthListener != null) + return; + + var _token = token ?? CancellationToken.None; + + _oauthListener = new HttpListener(); + _oauthListener.Prefixes.Add("http://localhost:9900/"); + _oauthListener.Start(); + + _oauthWaiterTask = WaitForCallback(callback, _token); + + try + { + Process.Start(url); + } + catch + { + // hack because of this: https://github.com/dotnet/corefx/issues/10361 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + url = url.Replace("&", "^&"); + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); + } + else + { + throw; + } + } + } + + private static async Task WaitForCallback(Action? callback, CancellationToken token) + { + if (_oauthListener == null) + return; + + if (token.IsCancellationRequested) + return; + + Console.WriteLine("Starting token listener"); + + while (!token.IsCancellationRequested) + { + var ctx = await _oauthListener.GetContextAsync(); + var req = ctx.Request; + var res = ctx.Response; + + if (req.Url == null) + continue; + + Console.WriteLine("{0} {1}", req.HttpMethod, req.Url); + + // Response, that may contain the access token as fragment + // It must be extracted client-side in browser + if (req.Url.LocalPath == "/") + { + var buf = new byte[1024]; + var data = new StringBuilder(); + int bytesRead; + while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0) + { + data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead)); + } + + req.InputStream.Close(); + + Console.WriteLine(data.ToString()); + + res.StatusCode = 200; + await res.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(OAUTH_CLIENT_DOCUMENT).AsMemory(0, OAUTH_CLIENT_DOCUMENT.Length), token); + res.Close(); + } + + if (req.Url.LocalPath == "/login") + { + var buf = new byte[1024]; + var data = new StringBuilder(); + int bytesRead; + while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0) + { + data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead)); + } + + req.InputStream.Close(); + + var json = data.ToString(); + var jsonData = JsonObject.Parse(json); + + if (jsonData == null) + { + Console.WriteLine("[ERROR] Failed to parse JSON data:"); + Console.WriteLine(json); + + res.StatusCode = 400; + res.Close(); + continue; + } + + if (jsonData["access_token"] == null) + { + Console.WriteLine("[ERROR] Missing access_token in JSON data."); + + res.StatusCode = 400; + res.Close(); + continue; + } + + var accessToken = jsonData["access_token"]?.ToString(); + App.TwitchOAuthAccessToken = accessToken!; + + res.StatusCode = 200; + res.Close(); + + Console.WriteLine("[INFO] Received access token!"); + + callback?.Invoke(); + + _oauthListener.Stop(); + return; + } + } + } + + private const string OAUTH_CLIENT_DOCUMENT = +@" + +
+ BetterRaid Twitch Login +
+ +

Successfully logged in!

+ + + +"; +} \ No newline at end of file diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 051c68d..22deb9e 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -1,7 +1,8 @@ using System; -using Avalonia; +using System.Threading; using Avalonia.Controls; using BetterRaid.Extensions; +using BetterRaid.Misc; using BetterRaid.Models; using BetterRaid.Views; @@ -25,6 +26,8 @@ public partial class MainWindowViewModel : ViewModelBase set => SetProperty(ref _filter, value); } + public bool IsLoggedIn => App.TwitchApi != null; + public void ExitApplication() { //TODO polish later @@ -37,4 +40,16 @@ public partial class MainWindowViewModel : ViewModelBase about.ShowDialog(owner); about.CenterToOwner(); } + + public void LoginWithTwitch() + { + Tools.StartOAuthLogin(App.TwitchOAuthUrl, OnTwitchLoginCallback, CancellationToken.None); + } + + public void OnTwitchLoginCallback() + { + App.InitTwitchClient(overrideToken: true); + + OnPropertyChanged(nameof(IsLoggedIn)); + } } diff --git a/ViewModels/RaidButtonViewModel.cs b/ViewModels/RaidButtonViewModel.cs index 61985b2..5204ceb 100644 --- a/ViewModels/RaidButtonViewModel.cs +++ b/ViewModels/RaidButtonViewModel.cs @@ -94,13 +94,13 @@ public class RaidButtonViewModel : ViewModelBase private async Task GetChannelAsync(string channelName) { - if (App.TwitchAPI == null) + if (App.TwitchApi == null) return null; if (string.IsNullOrEmpty(channelName)) return null; - var channels = await App.TwitchAPI.Helix.Search.SearchChannelsAsync(channelName); + var channels = await App.TwitchApi.Helix.Search.SearchChannelsAsync(channelName); var exactChannel = channels.Channels.FirstOrDefault(c => c.BroadcasterLogin.Equals(channelName, StringComparison.CurrentCultureIgnoreCase)); return exactChannel; @@ -108,13 +108,13 @@ public class RaidButtonViewModel : ViewModelBase private async Task GetStreamAsync(Channel currentChannelData) { - if (App.TwitchAPI == null) + if (App.TwitchApi == null) return null; if (currentChannelData == null) return null; - var streams = await App.TwitchAPI.Helix.Streams.GetStreamsAsync(userLogins: [currentChannelData.BroadcasterLogin]); + var streams = await App.TwitchApi.Helix.Streams.GetStreamsAsync(userLogins: [currentChannelData.BroadcasterLogin]); var exactStream = streams.Streams.FirstOrDefault(s => s.UserLogin == currentChannelData.BroadcasterLogin); return exactStream; @@ -122,7 +122,7 @@ public class RaidButtonViewModel : ViewModelBase public async Task RaidChannel() { - if (App.TwitchAPI == null) + if (App.TwitchApi == null) return; if (Channel == null) @@ -132,7 +132,8 @@ public class RaidButtonViewModel : ViewModelBase try { - raid = await App.TwitchAPI.Helix.Raids.StartRaidAsync(App.TwitchBroadcasterId, Channel.BroadcasterId); + // TODO: Get own broadcaster id + raid = await App.TwitchApi.Helix.Raids.StartRaidAsync("", Channel.BroadcasterId); } catch (Exception e) { diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index 97d8b43..2cd6989 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -48,6 +48,29 @@ Orientation="Horizontal" Spacing="5"> + + @@ -61,9 +84,21 @@ + + diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index f7cc400..2fa2732 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -58,10 +58,19 @@ public partial class MainWindow : Window { GenerateRaidGrid(); } + + if (e.PropertyName == nameof(MainWindowViewModel.IsLoggedIn) && DataContext is MainWindowViewModel { IsLoggedIn: true }) + { + InitializeRaidChannels(); + GenerateRaidGrid(); + } } private void InitializeRaidChannels() { + if (DataContext is MainWindowViewModel { IsLoggedIn: false }) + return; + if (_autoUpdater?.IsBusy == false) { _autoUpdater?.CancelAsync(); @@ -100,6 +109,9 @@ public partial class MainWindow : Window private void GenerateRaidGrid() { + if (DataContext is MainWindowViewModel { IsLoggedIn: false }) + return; + foreach (var child in raidGrid.Children) { if (child is Button btn) @@ -216,6 +228,13 @@ public partial class MainWindow : Window public void UpdateChannelData() { + var loggedIn = Dispatcher.UIThread.Invoke(() => { + return (DataContext as MainWindowViewModel)?.IsLoggedIn ?? false; + }); + + if (loggedIn == false) + return; + foreach (var vm in _raidButtonVMs) { Task.Run(vm.GetOrUpdateChannelAsync);