using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Sockets; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; using System.Security.Permissions; using System.Text; using System.Threading; using System.Threading.Tasks; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using DG.Tweening; using DG.Tweening.Core; using DG.Tweening.Plugins.Options; using HarmonyLib; using Microsoft.CodeAnalysis; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using TMPro; using UnityEngine; using UnityEngine.UI; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyCompany("WKMusic")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0+57e91829fcde28c0bc52c8f4a67e18ff9a2b5a5b")] [assembly: AssemblyProduct("WKMusic")] [assembly: AssemblyTitle("WKMusic")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.0.0.0")] [module: UnverifiableCode] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } internal static class IsExternalInit { } } namespace WKMusic { internal static class BrowserSetup { public sealed record SetupResult(string ClientId, string ClientSecret, SpotifyToken Token); private sealed class FlowState { public string? CredId; public string? CredSecret; public string? OAuthState; public Func>? ExchangeCode; } private static readonly HttpClient _http = new HttpClient(); private static readonly string[] Scopes = new string[3] { "user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing" }; private const string BaseStyle = "\n"; public static async Task RunAsync(string workerUrl, Func> exchangeCode, string initialClientId = "", string initialClientSecret = "", CancellationToken ct = default(CancellationToken)) { int port = FindFreePort(); string redirectUri = workerUrl + "/callback"; string credId = NullIfEmpty(initialClientId); string credSecret = NullIfEmpty(initialClientSecret); TaskCompletionSource tcs = new TaskCompletionSource(); ct.Register(delegate { tcs.TrySetResult(null); }); HttpListener listener = new HttpListener(); listener.Prefixes.Add($"http://localhost:{port}/"); listener.Start(); FlowState state = new FlowState { CredId = credId, CredSecret = credSecret, ExchangeCode = exchangeCode }; Task.Run(async delegate { try { while (!tcs.Task.IsCompleted) { HttpListenerContext ctx; try { ctx = await listener.GetContextAsync(); } catch { break; } Task.Run(() => Handle(ctx, port, redirectUri, state, tcs)); } } catch { } finally { listener.Stop(); } }); OpenBrowser($"http://localhost:{port}/"); Plugin.Logger.LogInfo((object)$"WKMusic: setup → http://localhost:{port}/"); return await tcs.Task; } private static async Task Handle(HttpListenerContext ctx, int port, string redirectUri, FlowState s, TaskCompletionSource tcs) { HttpListenerRequest request = ctx.Request; HttpListenerResponse res = ctx.Response; string absolutePath = request.Url.AbsolutePath; try { if (request.HttpMethod == "GET" && absolutePath == "/") { if (s.CredId == null || s.CredSecret == null) { await WriteHtml(res, PageCredentials()); return; } s.OAuthState = $"{Guid.NewGuid():N}:{port}"; Redirect(res, BuildAuthUrl(s.CredId, s.OAuthState, redirectUri)); } else if (request.HttpMethod == "POST" && absolutePath == "/submit") { Dictionary obj = await ReadForm(request); obj.TryGetValue("clientId", out var id2); obj.TryGetValue("clientSecret", out var secret2); id2 = id2?.Trim() ?? ""; secret2 = secret2?.Trim() ?? ""; string text = ValidateCredentials(id2, secret2); if (text == null) { text = await VerifyWithSpotifyAsync(id2, secret2); } string text2 = text; if (text2 != null) { await WriteHtml(res, PageCredentials(id2, text2)); return; } s.CredId = id2; s.CredSecret = secret2; s.OAuthState = $"{Guid.NewGuid():N}:{port}"; Redirect(res, BuildAuthUrl(s.CredId, s.OAuthState, redirectUri)); } else if (request.HttpMethod == "GET" && absolutePath == "/callback") { NameValueCollection queryString = request.QueryString; string text3 = queryString["code"]; string text4 = queryString["state"]; string text5 = queryString["error"]; if (!string.IsNullOrEmpty(text5)) { await WriteHtml(res, PageError("Spotify error: " + text5)); tcs.TrySetResult(null); } else if (text4 != s.OAuthState) { await WriteHtml(res, PageError("State mismatch — please try again.")); tcs.TrySetResult(null); } else if (!string.IsNullOrEmpty(text3) && s.CredId != null && s.CredSecret != null && s.ExchangeCode != null) { try { SpotifyToken token = await s.ExchangeCode(s.CredId, s.CredSecret, text3); await WriteHtml(res, PageSuccess()); tcs.TrySetResult(new SetupResult(s.CredId, s.CredSecret, token)); } catch (Exception ex) { await WriteHtml(res, PageError("Token exchange failed: " + ex.Message)); tcs.TrySetResult(null); } } else { await WriteHtml(res, PageError("Missing authorization code.")); tcs.TrySetResult(null); } } else { res.StatusCode = 404; res.Close(); } } catch (Exception ex2) { Plugin.Logger.LogWarning((object)("WKMusic: BrowserSetup handler error: " + ex2.Message)); try { res.Abort(); } catch { } } } private static string? ValidateCredentials(string id, string secret) { if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(secret)) { return "Both fields are required."; } return null; } private static async Task VerifyWithSpotifyAsync(string clientId, string clientSecret) { _ = 1; try { FormUrlEncodedContent content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_secret"] = clientSecret }); HttpResponseMessage httpResponseMessage = await _http.PostAsync("https://accounts.spotify.com/api/token", content); if (httpResponseMessage.IsSuccessStatusCode) { return null; } JToken obj = JObject.Parse(await httpResponseMessage.Content.ReadAsStringAsync())["error"]; string text = ((obj != null) ? Extensions.Value((IEnumerable)obj) : null) ?? ""; return (text == "invalid_client") ? "Invalid Client ID or Client Secret — check your Spotify Dashboard." : ("Spotify returned: " + text); } catch { return null; } } private static string BuildAuthUrl(string clientId, string state, string redirectUri) { string text = string.Join("&", new Dictionary { ["client_id"] = clientId, ["response_type"] = "code", ["redirect_uri"] = redirectUri, ["scope"] = string.Join(" ", Scopes), ["state"] = state }.Select((KeyValuePair kv) => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value))); return "https://accounts.spotify.com/authorize?" + text; } private static async Task WriteHtml(HttpListenerResponse res, string html) { byte[] bytes = Encoding.UTF8.GetBytes(html); res.ContentType = "text/html; charset=utf-8"; res.ContentLength64 = bytes.Length; await res.OutputStream.WriteAsync(bytes, 0, bytes.Length); res.Close(); } private static void Redirect(HttpListenerResponse res, string url) { res.StatusCode = 302; res.Headers["Location"] = url; res.Close(); } private static async Task> ReadForm(HttpListenerRequest req) { using StreamReader reader = new StreamReader(req.InputStream, Encoding.UTF8); string obj = await reader.ReadToEndAsync(); Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); string[] array = obj.Split('&'); foreach (string text in array) { int num = text.IndexOf('='); if (num >= 0) { string key = Uri.UnescapeDataString(text.Substring(0, num).Replace('+', ' ')); string text2 = text; int num2 = num + 1; dictionary[key] = Uri.UnescapeDataString(text2.Substring(num2, text2.Length - num2).Replace('+', ' ')); } } return dictionary; } private static int FindFreePort() { TcpListener tcpListener = new TcpListener(IPAddress.Loopback, 0); tcpListener.Start(); int port = ((IPEndPoint)tcpListener.LocalEndpoint).Port; tcpListener.Stop(); return port; } private static void OpenBrowser(string url) { try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch { } } private static string? NullIfEmpty(string s) { if (!string.IsNullOrWhiteSpace(s)) { return s.Trim(); } return null; } private static string PageCredentials(string initialId = "", string error = "") { return "\nWKMusic — Spotify Setup\n\n
\n

\ud83c\udfb5 WKMusic — Spotify Setup

\n

One-time setup. Token is saved locally.

\n " + (string.IsNullOrEmpty(error) ? "" : ("

" + error + "

")) + "\n
\n \n \n \n \n \n
\n

\n Get your credentials at developer.spotify.com/dashboard.
\n Add https://wkmusic.eventeventeventevent1.workers.dev/callback as a Redirect URI.\n

\n
"; } private static string PageSuccess() { return "\nWKMusic — Connected\n\n\n
\n
\ud83c\udfb5
\n

Connected!

\n

Spotify is linked. You can close this tab and return to the game.

\n
"; } private static string PageError(string message) { return "\nWKMusic — Error\n\n\n
\n

⚠ Error

\n

" + message + "

\n
"; } } public class CredentialsStorage { public sealed record SpotifyCredentials(string ClientId, string ClientSecret); private static class NativeMethods { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct CREDENTIAL { public uint Flags; public uint Type; public string TargetName; public string? Comment; public long LastWritten; public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist; public uint AttributeCount; public IntPtr Attributes; public string? TargetAlias; public string? UserName; } public const uint CRED_TYPE_GENERIC = 1u; public const uint CRED_PERSIST_LOCAL_MACHINE = 2u; [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredWrite(ref CREDENTIAL credential, uint flags); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr buffer); [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] public static extern bool CredDelete(string target, uint type, uint flags); } private const string CredentialName = "WKMusic/SpotifyCredentials"; public void Save(string clientId, string clientSecret) { string s = JsonConvert.SerializeObject((object)new { ClientId = clientId, ClientSecret = clientSecret }); byte[] bytes = Encoding.UTF8.GetBytes(s); NativeMethods.CREDENTIAL cREDENTIAL = default(NativeMethods.CREDENTIAL); cREDENTIAL.Type = 1u; cREDENTIAL.TargetName = "WKMusic/SpotifyCredentials"; cREDENTIAL.CredentialBlob = Marshal.AllocHGlobal(bytes.Length); cREDENTIAL.CredentialBlobSize = (uint)bytes.Length; cREDENTIAL.Persist = 2u; cREDENTIAL.UserName = "wkmusic"; NativeMethods.CREDENTIAL credential = cREDENTIAL; Marshal.Copy(bytes, 0, credential.CredentialBlob, bytes.Length); try { if (!NativeMethods.CredWrite(ref credential, 0u)) { throw new Exception($"CredWrite failed: {Marshal.GetLastWin32Error()}"); } } finally { Marshal.FreeHGlobal(credential.CredentialBlob); } } public SpotifyCredentials? Load() { if (!NativeMethods.CredRead("WKMusic/SpotifyCredentials", 1u, 0u, out var credential)) { return null; } try { NativeMethods.CREDENTIAL cREDENTIAL = Marshal.PtrToStructure(credential); byte[] array = new byte[cREDENTIAL.CredentialBlobSize]; Marshal.Copy(cREDENTIAL.CredentialBlob, array, 0, array.Length); var anon = JsonConvert.DeserializeAnonymousType(Encoding.UTF8.GetString(array), new { ClientId = "", ClientSecret = "" }); if (anon == null || string.IsNullOrWhiteSpace(anon.ClientId)) { return null; } return new SpotifyCredentials(anon.ClientId, anon.ClientSecret); } catch { Clear(); return null; } finally { NativeMethods.CredFree(credential); } } public void Clear() { NativeMethods.CredDelete("WKMusic/SpotifyCredentials", 1u, 0u); } } public interface IMusicClient { Task InitializeAsync(CancellationToken ct = default(CancellationToken)); Task GetPlaybackStateAsync(CancellationToken ct = default(CancellationToken)); } internal static class MyPluginInfo { public const string PLUGIN_GUID = "dev.wkmusic"; public const string PLUGIN_NAME = "WKMusic"; public const string PLUGIN_VERSION = "1.0.0"; } [BepInPlugin("dev.wkmusic", "WKMusic", "1.0.0")] public class Plugin : BaseUnityPlugin { internal static ManualLogSource Logger = null; internal static Plugin Instance = null; internal volatile PlaybackState State = PlaybackState.Empty; internal volatile bool Initialized; internal volatile bool Initializing; internal DateTime StateUpdatedAtUtc = DateTime.UtcNow; internal volatile byte[]? PendingCoverBytes; internal static float PlayerScale = 0.8f; internal static float CoverSize = 64f; internal static float CoverOpacity = 0.6f; private void Awake() { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_007f: Expected O, but got Unknown //IL_00b7: Unknown result type (might be due to invalid IL or missing references) //IL_00c1: Expected O, but got Unknown //IL_00f9: Unknown result type (might be due to invalid IL or missing references) //IL_0103: Expected O, but got Unknown //IL_0160: Unknown result type (might be due to invalid IL or missing references) Instance = this; Logger = ((BaseUnityPlugin)this).Logger; string workerUrl = ((BaseUnityPlugin)this).Config.Bind("Spotify", "WorkerUrl", "https://wkmusic.eventeventeventevent1.workers.dev", "Cloudflare Worker URL; change only if you host your own").Value; PlayerScale = ((BaseUnityPlugin)this).Config.Bind("HUD", "PlayerScale", 0.8f, new ConfigDescription("Overall player scale (affects font size and layout dimensions)", (AcceptableValueBase)(object)new AcceptableValueRange(0.5f, 3f), Array.Empty())).Value; CoverSize = ((BaseUnityPlugin)this).Config.Bind("HUD", "CoverSize", 64f, new ConfigDescription("Album cover size in pixels", (AcceptableValueBase)(object)new AcceptableValueRange(20f, 300f), Array.Empty())).Value; CoverOpacity = ((BaseUnityPlugin)this).Config.Bind("HUD", "CoverOpacity", 0.6f, new ConfigDescription("Album cover opacity (0 = invisible, 1 = fully opaque)", (AcceptableValueBase)(object)new AcceptableValueRange(0f, 1f), Array.Empty())).Value; CredentialsStorage credStorage = new CredentialsStorage(); CredentialsStorage.SpotifyCredentials saved = credStorage.Load(); Task.Run(async delegate { Initializing = true; try { TokenStorage tokenStorage = new TokenStorage(); string clientId; string clientSecret; if ((object)saved == null) { Logger.LogInfo((object)"WKMusic: credentials missing, opening browser setup..."); BrowserSetup.SetupResult setupResult = await BrowserSetup.RunAsync(workerUrl, delegate(string id, string secret, string code) { SpotifyAuth spotifyAuth2 = new SpotifyAuth(workerUrl, secret); string redirectUri2 = workerUrl + "/callback"; return spotifyAuth2.ExchangeCodeAsync(id, code, redirectUri2, default(CancellationToken)); }); if ((object)setupResult == null) { Logger.LogWarning((object)"WKMusic: setup cancelled."); return; } credStorage.Save(setupResult.ClientId, setupResult.ClientSecret); tokenStorage.Save(setupResult.Token); clientId = setupResult.ClientId; clientSecret = setupResult.ClientSecret; } else { clientId = saved.ClientId; clientSecret = saved.ClientSecret; } SpotifyClient client = new SpotifyClient(new SpotifyAuth(workerUrl, clientSecret), tokenStorage, clientId); try { await client.InitializeAsync(); } catch (SpotifyAuthException ex) when ((object)saved != null && ex.Error == "invalid_client") { Logger.LogWarning((object)"WKMusic: saved Spotify credentials were rejected; clearing them and reopening setup."); tokenStorage.Clear(); credStorage.Clear(); BrowserSetup.SetupResult setupResult2 = await BrowserSetup.RunAsync(workerUrl, delegate(string id, string secret, string code) { SpotifyAuth spotifyAuth = new SpotifyAuth(workerUrl, secret); string redirectUri = workerUrl + "/callback"; return spotifyAuth.ExchangeCodeAsync(id, code, redirectUri, default(CancellationToken)); }, clientId); if ((object)setupResult2 == null) { Logger.LogWarning((object)"WKMusic: setup cancelled."); return; } credStorage.Save(setupResult2.ClientId, setupResult2.ClientSecret); tokenStorage.Save(setupResult2.Token); client = new SpotifyClient(new SpotifyAuth(workerUrl, setupResult2.ClientSecret), tokenStorage, setupResult2.ClientId); await client.InitializeAsync(); } Initialized = true; Logger.LogInfo((object)"WKMusic: Spotify connected."); StartPolling(client); } catch (Exception ex2) { Logger.LogError((object)("WKMusic: init failed — " + ex2.GetType().Name + ": " + ex2.Message)); Logger.LogError((object)(ex2.StackTrace ?? "")); } finally { Initializing = false; } }); DOTween.Init((bool?)null, (bool?)null, (LogBehaviour?)null); new Harmony("dev.wkmusic").PatchAll(); } private void StartPolling(IMusicClient client) { IMusicClient client2 = client; HttpClient http = new HttpClient(); string lastCoverToken = null; Task.Run(async delegate { while (true) { int delayMs; try { PlaybackState state = await client2.GetPlaybackStateAsync(); State = state; StateUpdatedAtUtc = DateTime.UtcNow; delayMs = 1000; TrackInfo track = State.Track; string text = (((object)track == null) ? null : ((track.CoverBytes != null) ? ("embedded:" + track.Id) : track.CoverUrl)); if (text != lastCoverToken) { lastCoverToken = text; byte[] pendingCoverBytes; if (track?.CoverBytes != null) { pendingCoverBytes = track.CoverBytes; } else { byte[] array = (((object)track == null || track.CoverUrl == null) ? Array.Empty() : (await http.GetByteArrayAsync(track.CoverUrl))); pendingCoverBytes = array; } PendingCoverBytes = pendingCoverBytes; } } catch (SpotifyRateLimitException ex) { int num = Math.Max(1000, (int)Math.Ceiling(ex.RetryAfter.TotalMilliseconds)); delayMs = ((num <= 30000) ? num : num); string statusMessage = ((num > 30000) ? ("Spotify rate limited (" + FormatDelay(delayMs) + ")") : "Spotify rate limited"); State = State with { StatusMessage = statusMessage }; StateUpdatedAtUtc = DateTime.UtcNow; Logger.LogWarning((object)($"WKMusic: Spotify rate limited polling; retrying in {delayMs} ms. " + string.Format("Retry-After={0}, requested={1} ms.", ex.RawRetryAfter ?? "missing", num))); } catch (Exception ex2) { delayMs = 5000; Logger.LogWarning((object)("WKMusic: poll error - " + ex2.GetType().Name + ": " + ex2.Message + "\n" + ex2.StackTrace)); } await Task.Delay(delayMs); } }); } internal static string Truncate(string s, int max) { if (s.Length > max) { return s.Substring(0, max - 3) + "..."; } return s; } private static string FormatDelay(int delayMs) { TimeSpan timeSpan = TimeSpan.FromMilliseconds(delayMs); if (timeSpan.TotalHours >= 1.0) { return $"{(int)timeSpan.TotalHours}h {timeSpan.Minutes}m"; } if (timeSpan.TotalMinutes >= 1.0) { return $"{(int)timeSpan.TotalMinutes}m {timeSpan.Seconds}s"; } return $"{timeSpan.Seconds}s"; } } [HarmonyPatch(typeof(CL_UIManager), "Awake")] internal static class Patch_UIManager_Awake { private const float RightMargin = 10f; private const float TextWidth = 320f; private const float CoverGap = 10f; private static void Postfix(CL_UIManager __instance) { //IL_006c: Unknown result type (might be due to invalid IL or missing references) //IL_0071: Unknown result type (might be due to invalid IL or missing references) //IL_007e: Unknown result type (might be due to invalid IL or missing references) //IL_0097: Unknown result type (might be due to invalid IL or missing references) //IL_009e: Unknown result type (might be due to invalid IL or missing references) //IL_00b0: Unknown result type (might be due to invalid IL or missing references) //IL_00bb: Unknown result type (might be due to invalid IL or missing references) //IL_00c5: Unknown result type (might be due to invalid IL or missing references) //IL_00cf: Unknown result type (might be due to invalid IL or missing references) //IL_0111: Unknown result type (might be due to invalid IL or missing references) //IL_013f: Unknown result type (might be due to invalid IL or missing references) Plugin.Logger.LogInfo((object)"WKMusic: CL_UIManager.Awake fired."); Transform canvas = __instance.canvas; TMP_Text timer = __instance.timer; if ((Object)(object)canvas == (Object)null || (Object)(object)timer == (Object)null) { Plugin.Logger.LogWarning((object)$"WKMusic: canvas={canvas}, timer={timer} - aborting."); return; } float playerScale = Plugin.PlayerScale; float coverSize = Plugin.CoverSize; float coverRight = 10f + 320f * playerScale + 10f; GameObject val = new GameObject("WKMusic_HUD"); val.transform.SetParent(canvas, false); RectTransform obj = val.AddComponent(); Vector2 val2 = default(Vector2); ((Vector2)(ref val2))..ctor(1f, 1f); obj.anchorMax = val2; obj.anchorMin = val2; obj.pivot = new Vector2(1f, 1f); obj.anchoredPosition = Vector2.zero; obj.sizeDelta = Vector2.zero; Patch_UIManager_Update.HudGroup = val.AddComponent(); Transform transform = val.transform; Patch_UIManager_Update.CoverA = CreateCover(transform, coverSize, coverRight); Patch_UIManager_Update.CoverB = CreateCover(transform, coverSize, coverRight); Patch_UIManager_Update.RestoreCover(); Patch_UIManager_Update.TrackLabel = CreateLabel(transform, timer, "WKMusic_Track", new Vector2(-10f, -10f), playerScale, (TextAlignmentOptions)258, 42f * playerScale); Patch_UIManager_Update.ProgressLabel = CreateLabel(transform, timer, "WKMusic_Progress", new Vector2(-10f, -62f * playerScale), playerScale, (TextAlignmentOptions)257); Patch_UIManager_Update.ResetCache(); if (!Plugin.Instance.Initialized) { Patch_UIManager_Update.TrackLabel.text = "WKMusic: loading..."; } Plugin.Logger.LogInfo((object)"WKMusic: HUD created."); } private static RawImage CreateCover(Transform canvas, float size, float coverRight) { //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_000a: Unknown result type (might be due to invalid IL or missing references) //IL_0017: Unknown result type (might be due to invalid IL or missing references) //IL_0034: Unknown result type (might be due to invalid IL or missing references) //IL_0049: Unknown result type (might be due to invalid IL or missing references) //IL_005e: Unknown result type (might be due to invalid IL or missing references) //IL_0070: Unknown result type (might be due to invalid IL or missing references) //IL_007c: Unknown result type (might be due to invalid IL or missing references) GameObject val = new GameObject("WKMusic_Cover"); val.transform.SetParent(canvas, false); val.SetActive(false); RawImage obj = val.AddComponent(); RectTransform rectTransform = ((Graphic)obj).rectTransform; rectTransform.anchorMin = new Vector2(1f, 1f); rectTransform.anchorMax = new Vector2(1f, 1f); rectTransform.pivot = new Vector2(1f, 1f); rectTransform.anchoredPosition = new Vector2(0f - coverRight, -10f); rectTransform.sizeDelta = new Vector2(size, size); return obj; } private static TMP_Text CreateLabel(Transform canvas, TMP_Text reference, string name, Vector2 offset, float scale = 1f, TextAlignmentOptions alignment = 257, float height = 30f) { //IL_0001: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_003b: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Unknown result type (might be due to invalid IL or missing references) //IL_0073: Unknown result type (might be due to invalid IL or missing references) //IL_0088: Unknown result type (might be due to invalid IL or missing references) //IL_009d: Unknown result type (might be due to invalid IL or missing references) //IL_00a8: Unknown result type (might be due to invalid IL or missing references) //IL_00b8: Unknown result type (might be due to invalid IL or missing references) GameObject val = new GameObject(name); val.transform.SetParent(canvas, false); TextMeshProUGUI obj = val.AddComponent(); ((TMP_Text)obj).font = reference.font; ((TMP_Text)obj).fontSize = reference.fontSize * 0.85f * scale; ((Graphic)obj).color = ((Graphic)reference).color; ((TMP_Text)obj).alignment = alignment; ((TMP_Text)obj).enableWordWrapping = false; ((TMP_Text)obj).overflowMode = (TextOverflowModes)0; ((TMP_Text)obj).richText = true; RectTransform rectTransform = ((TMP_Text)obj).rectTransform; rectTransform.anchorMin = new Vector2(1f, 1f); rectTransform.anchorMax = new Vector2(1f, 1f); rectTransform.pivot = new Vector2(1f, 1f); rectTransform.anchoredPosition = offset; rectTransform.sizeDelta = new Vector2(320f * scale, height); return (TMP_Text)(object)obj; } } [HarmonyPatch(typeof(CL_UIManager), "Update")] internal static class Patch_UIManager_Update { internal static TMP_Text? TrackLabel; internal static TMP_Text? ProgressLabel; internal static CanvasGroup? HudGroup; internal static RawImage? CoverA; internal static RawImage? CoverB; private static bool _aIsFront = true; private static Texture2D? _coverTexA; private static Texture2D? _coverTexB; private static byte[]? _lastCoverBytes; private static bool _wasPaused; private static string? _lastTrackText; private static float _trackAlpha = 1f; private static RawImage? Front { get { if (!_aIsFront) { return CoverB; } return CoverA; } } private static RawImage? Back { get { if (!_aIsFront) { return CoverA; } return CoverB; } } internal static void ResetCache() { if ((Object)(object)TrackLabel != (Object)null) { TrackLabel.text = ""; } if ((Object)(object)ProgressLabel != (Object)null) { ProgressLabel.text = ""; } _wasPaused = false; _lastTrackText = null; _trackAlpha = 1f; DOTween.Kill((object)"coverA", false); DOTween.Kill((object)"coverB", false); _aIsFront = true; } internal static void RestoreCover() { //IL_004b: Unknown result type (might be due to invalid IL or missing references) if (_lastCoverBytes == null) { return; } RawImage front = Front; if (!((Object)(object)front == (Object)null)) { SetLayerTexture(front, ref _aIsFront ? ref _coverTexA : ref _coverTexB, _lastCoverBytes); ((Graphic)front).color = new Color(1f, 1f, 1f, Plugin.CoverOpacity); ((Component)front).gameObject.SetActive(true); RawImage back = Back; if ((Object)(object)back != (Object)null) { ((Component)back).gameObject.SetActive(false); } } } private static void SetLayerTexture(RawImage layer, ref Texture2D? texSlot, byte[] bytes) { //IL_0017: Unknown result type (might be due to invalid IL or missing references) //IL_001d: Expected O, but got Unknown if ((Object)(object)texSlot != (Object)null) { Object.Destroy((Object)(object)texSlot); } texSlot = new Texture2D(2, 2, (TextureFormat)4, false, false); if (ImageConversion.LoadImage(texSlot, bytes)) { ((Texture)texSlot).wrapMode = (TextureWrapMode)1; texSlot.Apply(); } layer.texture = (Texture)(object)texSlot; } internal static void ApplyCover(byte[] bytes) { //IL_007d: Unknown result type (might be due to invalid IL or missing references) //IL_0104: Unknown result type (might be due to invalid IL or missing references) //IL_013a: Unknown result type (might be due to invalid IL or missing references) //IL_0144: Expected O, but got Unknown RawImage back = Back; if ((Object)(object)back == (Object)null) { return; } DOTween.Kill((object)"coverA", false); DOTween.Kill((object)"coverB", false); if (_aIsFront) { SetLayerTexture(back, ref _coverTexB, bytes); } else { SetLayerTexture(back, ref _coverTexA, bytes); } ((Graphic)back).color = new Color(1f, 1f, 1f, 0f); ((Component)back).gameObject.SetActive(true); ((Component)back).transform.SetAsLastSibling(); RawImage front = Front; TweenSettingsExtensions.SetId(DOVirtual.Float(0f, Plugin.CoverOpacity, 0.6f, (TweenCallback)delegate(float v) { //IL_0024: Unknown result type (might be due to invalid IL or missing references) if ((Object)(object)back != (Object)null) { ((Graphic)back).color = new Color(1f, 1f, 1f, v); } }), "coverB"); if ((Object)(object)front != (Object)null && ((Component)front).gameObject.activeSelf) { TweenSettingsExtensions.OnComplete(TweenSettingsExtensions.SetId(DOVirtual.Float(((Graphic)front).color.a, 0f, 0.6f, (TweenCallback)delegate(float v) { //IL_0024: Unknown result type (might be due to invalid IL or missing references) if ((Object)(object)front != (Object)null) { ((Graphic)front).color = new Color(1f, 1f, 1f, v); } }), "coverA"), (TweenCallback)delegate { if ((Object)(object)front != (Object)null) { ((Component)front).gameObject.SetActive(false); } }); } _aIsFront = !_aIsFront; } internal static void HideCover() { //IL_0050: Unknown result type (might be due to invalid IL or missing references) //IL_0086: Unknown result type (might be due to invalid IL or missing references) //IL_0090: Expected O, but got Unknown DOTween.Kill((object)"coverA", false); DOTween.Kill((object)"coverB", false); RawImage front = Front; if ((Object)(object)front == (Object)null || !((Component)front).gameObject.activeSelf) { return; } TweenSettingsExtensions.OnComplete(TweenSettingsExtensions.SetId(DOVirtual.Float(((Graphic)front).color.a, 0f, 0.5f, (TweenCallback)delegate(float v) { //IL_0024: Unknown result type (might be due to invalid IL or missing references) if ((Object)(object)front != (Object)null) { ((Graphic)front).color = new Color(1f, 1f, 1f, v); } }), "coverA"), (TweenCallback)delegate { if ((Object)(object)front != (Object)null) { ((Component)front).gameObject.SetActive(false); } }); } private static void SetTrackLabel(string newText) { //IL_0067: Unknown result type (might be due to invalid IL or missing references) //IL_006c: Unknown result type (might be due to invalid IL or missing references) //IL_0072: Unknown result type (might be due to invalid IL or missing references) //IL_0078: Unknown result type (might be due to invalid IL or missing references) //IL_007e: Unknown result type (might be due to invalid IL or missing references) //IL_0089: Unknown result type (might be due to invalid IL or missing references) if (!((Object)(object)TrackLabel == (Object)null)) { if (_lastTrackText != newText) { _lastTrackText = newText; TrackLabel.text = newText; _trackAlpha = 0f; } if (_trackAlpha < 1f) { _trackAlpha = Mathf.Min(1f, _trackAlpha + Time.deltaTime * 2.5f); Color color = ((Graphic)TrackLabel).color; ((Graphic)TrackLabel).color = new Color(color.r, color.g, color.b, _trackAlpha); } } } private static void Postfix() { if ((Object)(object)TrackLabel == (Object)null || (Object)(object)ProgressLabel == (Object)null) { return; } Plugin instance = Plugin.Instance; byte[] pendingCoverBytes = instance.PendingCoverBytes; if (pendingCoverBytes != null) { instance.PendingCoverBytes = null; if (pendingCoverBytes.Length == 0) { _lastCoverBytes = null; HideCover(); } else { _lastCoverBytes = pendingCoverBytes; ApplyCover(pendingCoverBytes); } } if (instance.Initializing) { SetTrackLabel("WKMusic: authorizing..."); ProgressLabel.text = ""; return; } if (!instance.Initialized) { SetTrackLabel(""); ProgressLabel.text = ""; return; } PlaybackState state = instance.State; if (!string.IsNullOrEmpty(state.StatusMessage)) { SetTrackLabel(state.StatusMessage); ProgressLabel.text = ""; return; } if ((Object)(object)HudGroup != (Object)null) { bool flag = state.Track != null && !state.IsPlaying; if (flag != _wasPaused) { _wasPaused = flag; DOVirtual.Float(HudGroup.alpha, flag ? 0.3f : 1f, 0.5f, (TweenCallback)delegate(float v) { HudGroup.alpha = v; }); TweenSettingsExtensions.SetEase>(ShortcutExtensions.DOScale(((Component)HudGroup).transform, flag ? 0.75f : 1f, 0.5f), (Ease)9); } } if ((object)state.Track == null) { SetTrackLabel("Nothing playing"); ProgressLabel.text = ""; return; } TrackInfo track = state.Track; int value = (state.IsPlaying ? (state.ProgressMs + (int)(DateTime.UtcNow - instance.StateUpdatedAtUtc).TotalMilliseconds) : state.ProgressMs); value = Math.Clamp(value, 0, track.DurationMs); TimeSpan timeSpan = TimeSpan.FromMilliseconds(value); TimeSpan timeSpan2 = TimeSpan.FromMilliseconds(track.DurationMs); int num = Math.Clamp((int)Math.Round(((track.DurationMs > 0) ? ((double)value / (double)track.DurationMs) : 0.0) * 50.0), 0, 50); string arg = new string('-', num) + "|" + new string('-', 50 - num) + ""; SetTrackLabel(Plugin.Truncate(track.Title, 40) + "\n" + Plugin.Truncate(track.Artist, 40) + ""); ProgressLabel.text = $"{timeSpan:m\\:ss} {arg} {timeSpan2:m\\:ss}"; } } public class SpotifyAuthException : Exception { public string? Error { get; } public string ResponseBody { get; } public SpotifyAuthException(string message, string? error, string responseBody) { Error = error; ResponseBody = responseBody; base..ctor(message); } } public class SpotifyAuth { private const string TokenUrl = "https://accounts.spotify.com/api/token"; private readonly HttpClient _http = http ?? new HttpClient(); private readonly string _workerUrl = workerUrl.TrimEnd('/'); private readonly string _clientSecret; public SpotifyAuth(string workerUrl, string clientSecret, HttpClient? http = null) { _clientSecret = clientSecret; base..ctor(); } public async Task AuthorizeAsync(string clientId, CancellationToken ct = default(CancellationToken)) { string redirectUri = _workerUrl + "/callback"; return ((await BrowserSetup.RunAsync(_workerUrl, (string id, string _, string code) => ExchangeCodeAsync(id, code, redirectUri, ct), clientId, "", ct)) ?? throw new OperationCanceledException("Spotify authorization cancelled.")).Token; } public async Task RefreshAsync(string clientId, SpotifyToken token, CancellationToken ct = default(CancellationToken)) { Dictionary nameValueCollection = new Dictionary { ["grant_type"] = "refresh_token", ["refresh_token"] = token.RefreshToken, ["client_id"] = clientId, ["client_secret"] = _clientSecret }; HttpResponseMessage response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(nameValueCollection), ct); string text = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { string error = TryReadSpotifyError(text); throw new SpotifyAuthException("Token refresh failed: " + text, error, text); } JObject val = JObject.Parse(text); string accessToken = Extensions.Value((IEnumerable)val["access_token"]); JToken obj = val["refresh_token"]; return new SpotifyToken(accessToken, ((obj != null) ? Extensions.Value((IEnumerable)obj) : null) ?? token.RefreshToken, Extensions.Value((IEnumerable)val["expires_in"]), DateTime.UtcNow); } internal async Task ExchangeCodeAsync(string clientId, string code, string redirectUri, CancellationToken ct) { Dictionary nameValueCollection = new Dictionary { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = redirectUri, ["client_id"] = clientId, ["client_secret"] = _clientSecret }; HttpResponseMessage response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(nameValueCollection), ct); string text = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { string error = TryReadSpotifyError(text); throw new SpotifyAuthException("Token exchange failed: " + text, error, text); } JObject val = JObject.Parse(text); string accessToken = Extensions.Value((IEnumerable)val["access_token"]); JToken obj = val["refresh_token"]; return new SpotifyToken(accessToken, ((obj != null) ? Extensions.Value((IEnumerable)obj) : null) ?? string.Empty, Extensions.Value((IEnumerable)val["expires_in"]), DateTime.UtcNow); } private static string? TryReadSpotifyError(string json) { try { JToken obj = JObject.Parse(json)["error"]; return (obj != null) ? Extensions.Value((IEnumerable)obj) : null; } catch { return null; } } } public class SpotifyRateLimitException : Exception { public TimeSpan RetryAfter { get; } public string? RawRetryAfter { get; } public SpotifyRateLimitException(TimeSpan retryAfter, string? rawRetryAfter) { RetryAfter = retryAfter; RawRetryAfter = rawRetryAfter; base..ctor($"Spotify rate limit reached. Retry after {retryAfter.TotalSeconds:0.#}s."); } } public class SpotifyClient : IMusicClient { [CompilerGenerated] private SpotifyAuth P; [CompilerGenerated] private TokenStorage P; private const string ApiBase = "https://api.spotify.com/v1"; private readonly HttpClient _http; private readonly string _clientId; private SpotifyToken? _token; public SpotifyClient(SpotifyAuth auth, TokenStorage storage, string clientId, HttpClient? http = null) { P = auth; P = storage; _http = http ?? new HttpClient(); _clientId = clientId; base..ctor(); } public async Task InitializeAsync(CancellationToken ct = default(CancellationToken)) { _token = P.Load(); if ((object)_token == null) { _token = await P.AuthorizeAsync(_clientId, ct); P.Save(_token); } else if (_token.IsExpired) { await ForceRefreshAsync(ct); } } public async Task GetPlaybackStateAsync(CancellationToken ct = default(CancellationToken)) { string text = await GetAsync("/me/player", ct); if (text == null) { return PlaybackState.Empty; } JObject val = JObject.Parse(text); JToken val2 = val["item"]; if (val2 == null || (int)val2.Type == 10) { return PlaybackState.Empty; } TrackInfo track = ParseTrack(val2); bool isPlaying = Extensions.Value((IEnumerable)val["is_playing"]); int progressMs = Extensions.Value((IEnumerable)val["progress_ms"]); return new PlaybackState(track, isPlaying, progressMs); } public Task PlayAsync(CancellationToken ct = default(CancellationToken)) { return PutAsync("/me/player/play", ct); } public Task PauseAsync(CancellationToken ct = default(CancellationToken)) { return PutAsync("/me/player/pause", ct); } public Task NextAsync(CancellationToken ct = default(CancellationToken)) { return PostAsync("/me/player/next", ct); } public Task PreviousAsync(CancellationToken ct = default(CancellationToken)) { return PostAsync("/me/player/previous", ct); } public Task SeekAsync(int positionMs, CancellationToken ct = default(CancellationToken)) { return PutAsync($"/me/player/seek?position_ms={positionMs}", ct); } public Task SetVolumeAsync(int volumePercent, CancellationToken ct = default(CancellationToken)) { return PutAsync($"/me/player/volume?volume_percent={Math.Clamp(volumePercent, 0, 100)}", ct); } private async Task GetAsync(string path, CancellationToken ct) { await EnsureTokenValidAsync(ct); HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1" + path); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken); HttpResponseMessage httpResponseMessage = await _http.SendAsync(httpRequestMessage, ct); if (httpResponseMessage.StatusCode == HttpStatusCode.NoContent) { return null; } if (httpResponseMessage.StatusCode == HttpStatusCode.Unauthorized) { await ForceRefreshAsync(ct); return await GetAsync(path, ct); } EnsureNotRateLimited(httpResponseMessage); httpResponseMessage.EnsureSuccessStatusCode(); return await httpResponseMessage.Content.ReadAsStringAsync(); } private async Task PutAsync(string path, CancellationToken ct) { await EnsureTokenValidAsync(ct); HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, "https://api.spotify.com/v1" + path); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken); httpRequestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); HttpResponseMessage httpResponseMessage = await _http.SendAsync(httpRequestMessage, ct); if (httpResponseMessage.StatusCode == HttpStatusCode.Unauthorized) { await ForceRefreshAsync(ct); await PutAsync(path, ct); } else if (httpResponseMessage.StatusCode != HttpStatusCode.NoContent) { EnsureNotRateLimited(httpResponseMessage); httpResponseMessage.EnsureSuccessStatusCode(); } } private async Task PostAsync(string path, CancellationToken ct) { await EnsureTokenValidAsync(ct); HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://api.spotify.com/v1" + path); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken); httpRequestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); HttpResponseMessage httpResponseMessage = await _http.SendAsync(httpRequestMessage, ct); if (httpResponseMessage.StatusCode == HttpStatusCode.Unauthorized) { await ForceRefreshAsync(ct); await PostAsync(path, ct); } else if (httpResponseMessage.StatusCode != HttpStatusCode.NoContent) { EnsureNotRateLimited(httpResponseMessage); httpResponseMessage.EnsureSuccessStatusCode(); } } private async Task EnsureTokenValidAsync(CancellationToken ct) { if ((object)_token == null) { throw new InvalidOperationException("Client not initialized. Call InitializeAsync first."); } if (_token.IsExpired) { await ForceRefreshAsync(ct); } } private async Task ForceRefreshAsync(CancellationToken ct) { try { _token = await P.RefreshAsync(_clientId, _token, ct); } catch (SpotifyAuthException ex) when (ex.Error == "invalid_grant") { P.Clear(); _token = await P.AuthorizeAsync(_clientId, ct); } P.Save(_token); } private static void EnsureNotRateLimited(HttpResponseMessage response) { if (response.StatusCode != HttpStatusCode.TooManyRequests) { return; } response.Headers.TryGetValues("Retry-After", out IEnumerable values); string rawRetryAfter = values?.FirstOrDefault(); TimeSpan timeSpan = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.FromSeconds(10.0); if (timeSpan < TimeSpan.FromSeconds(1.0)) { timeSpan = TimeSpan.FromSeconds(1.0); } throw new SpotifyRateLimitException(timeSpan, rawRetryAfter); } private static TrackInfo ParseTrack(JToken item) { string id = Extensions.Value((IEnumerable)item[(object)"id"]); string title = Extensions.Value((IEnumerable)item[(object)"name"]); IEnumerable values = ((IEnumerable)item[(object)"artists"]).Select((JToken a) => Extensions.Value((IEnumerable)a[(object)"name"])); string artist = string.Join(", ", values); string album = Extensions.Value((IEnumerable)item[(object)"album"][(object)"name"]); int durationMs = Extensions.Value((IEnumerable)item[(object)"duration_ms"]); JToken obj = item[(object)"album"][(object)"images"]; object obj2; if (obj == null) { obj2 = null; } else { JToken? obj3 = ((IEnumerable)obj).FirstOrDefault(); if (obj3 == null) { obj2 = null; } else { JToken obj4 = obj3[(object)"url"]; obj2 = ((obj4 != null) ? Extensions.Value((IEnumerable)obj4) : null); } } string coverUrl = (string)obj2; return new TrackInfo(id, title, artist, album, coverUrl, durationMs); } } public record SpotifyToken(string AccessToken, string RefreshToken, int ExpiresIn, DateTime ObtainedAt) { public bool IsExpired => DateTime.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn - 30); } public record TrackInfo(string Id, string Title, string Artist, string Album, string? CoverUrl, int DurationMs, byte[]? CoverBytes = null); public record PlaybackState(TrackInfo? Track, bool IsPlaying, int ProgressMs, string? StatusMessage = null) { public static PlaybackState Empty => new PlaybackState(null, IsPlaying: false, 0); } public class TokenStorage { private class StoredToken { public string AccessToken { get; set; } = ""; public string RefreshToken { get; set; } = ""; public int ExpiresIn { get; set; } public DateTime ObtainedAt { get; set; } public StoredToken() { } public StoredToken(string access, string refresh, int expiresIn, DateTime obtainedAt) { AccessToken = access; RefreshToken = refresh; ExpiresIn = expiresIn; ObtainedAt = obtainedAt; } } private static class NativeMethods { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct CREDENTIAL { public uint Flags; public uint Type; public string TargetName; public string? Comment; public long LastWritten; public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist; public uint AttributeCount; public IntPtr Attributes; public string? TargetAlias; public string? UserName; } public const uint CRED_TYPE_GENERIC = 1u; public const uint CRED_PERSIST_LOCAL_MACHINE = 2u; [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredWrite(ref CREDENTIAL credential, uint flags); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr buffer); [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] public static extern bool CredDelete(string target, uint type, uint flags); } private readonly string CredentialName; public TokenStorage(string credentialName = "WKMusic/SpotifyToken") { CredentialName = credentialName; base..ctor(); } public void Save(SpotifyToken token) { string s = JsonConvert.SerializeObject((object)new StoredToken(token.AccessToken, token.RefreshToken, token.ExpiresIn, token.ObtainedAt)); byte[] bytes = Encoding.UTF8.GetBytes(s); NativeMethods.CREDENTIAL cREDENTIAL = default(NativeMethods.CREDENTIAL); cREDENTIAL.Type = 1u; cREDENTIAL.TargetName = CredentialName; cREDENTIAL.CredentialBlob = Marshal.AllocHGlobal(bytes.Length); cREDENTIAL.CredentialBlobSize = (uint)bytes.Length; cREDENTIAL.Persist = 2u; cREDENTIAL.UserName = "spotify"; NativeMethods.CREDENTIAL credential = cREDENTIAL; Marshal.Copy(bytes, 0, credential.CredentialBlob, bytes.Length); try { if (!NativeMethods.CredWrite(ref credential, 0u)) { throw new Exception($"CredWrite failed: {Marshal.GetLastWin32Error()}"); } } finally { Marshal.FreeHGlobal(credential.CredentialBlob); } } public SpotifyToken? Load() { if (!NativeMethods.CredRead(CredentialName, 1u, 0u, out var credential)) { return null; } try { NativeMethods.CREDENTIAL cREDENTIAL = Marshal.PtrToStructure(credential); byte[] array = new byte[cREDENTIAL.CredentialBlobSize]; Marshal.Copy(cREDENTIAL.CredentialBlob, array, 0, array.Length); StoredToken storedToken = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(array)); if (storedToken == null) { return null; } return new SpotifyToken(storedToken.AccessToken, storedToken.RefreshToken, storedToken.ExpiresIn, storedToken.ObtainedAt); } catch { Clear(); return null; } finally { NativeMethods.CredFree(credential); } } public void Clear() { NativeMethods.CredDelete(CredentialName, 1u, 0u); } } }