From ae9b6219315b69b189b9eb1ed31cab629123a47b Mon Sep 17 00:00:00 2001 From: flashwave Date: Wed, 28 Jun 2023 22:12:23 +0200 Subject: [PATCH] Add project files. --- .gitattributes | 1 + .gitignore | 215 ++++++++++++++ README.md | 3 + Satori/FutamiCommon.cs | 26 ++ Satori/PersistentData.cs | 191 ++++++++++++ Satori/Program.cs | 80 +++++ .../PublishProfiles/FolderProfile.pubxml | 19 ++ Satori/RNG.cs | 32 ++ Satori/SockChatClient.cs | 276 ++++++++++++++++++ Satori/SockChatKeepAlive.csproj | 12 + SockChatKeepAlive.sln | 25 ++ 11 files changed, 880 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Satori/FutamiCommon.cs create mode 100644 Satori/PersistentData.cs create mode 100644 Satori/Program.cs create mode 100644 Satori/Properties/PublishProfiles/FolderProfile.pubxml create mode 100644 Satori/RNG.cs create mode 100644 Satori/SockChatClient.cs create mode 100644 Satori/SockChatKeepAlive.csproj create mode 100644 SockChatKeepAlive.sln diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40daee9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,215 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# JetBrains Rider cache/options directory +.idea/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dcd6fc --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# satori + +![](https://mikoto.misaka.nl/i/Dw0mMO_WsAI8Dyu.jpg) diff --git a/Satori/FutamiCommon.cs b/Satori/FutamiCommon.cs new file mode 100644 index 0000000..06be11f --- /dev/null +++ b/Satori/FutamiCommon.cs @@ -0,0 +1,26 @@ +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace SockChatKeepAlive { + public class FutamiCommon { + [JsonPropertyName("ping")] + public int Ping { get; set; } + + [JsonPropertyName("uiharu")] + public string Metadata { get; set; } + + [JsonPropertyName("eeprom")] + public string Uploads { get; set; } + + [JsonPropertyName("servers")] + public string[] Servers { get; set; } + + public static async Task FetchAsync(HttpClient client, string host) { + return JsonSerializer.Deserialize( + await client.GetByteArrayAsync(host + "/common.json") + ); + } + } +} diff --git a/Satori/PersistentData.cs b/Satori/PersistentData.cs new file mode 100644 index 0000000..72b6d55 --- /dev/null +++ b/Satori/PersistentData.cs @@ -0,0 +1,191 @@ +using System; +using System.Buffers; +using System.IO; +using System.Text; + +namespace SockChatKeepAlive { + public class PersistentData : IDisposable { + private const int MAGIC = 0x0BB0AFDE; + private const byte VERSION = 1; + + private Stream Stream { get; } + private bool OwnsStream { get; } + + private const int HEADER_SIZE = 0x10; + + private const int SAT_TOTAL_UPTIME = 0x10; + private const int SAT_TOTAL_UPTIME_LENGTH = 0x08; + private const int AFK_STR = SAT_TOTAL_UPTIME + SAT_TOTAL_UPTIME_LENGTH; + private const int AFK_STR_LENGTH = 0x14; + private const int GRACEFUL_DISCON = AFK_STR + AFK_STR_LENGTH; + + private readonly long OffsetStart; + + public long SatoriTotalUptime { + get => ReadI64(SAT_TOTAL_UPTIME); + set => WriteI64(SAT_TOTAL_UPTIME, value); + } + + public string AFKString { + get => ReadStr(AFK_STR, AFK_STR_LENGTH); + set => WriteStr(AFK_STR, AFK_STR_LENGTH, value); + } + + public bool WasGracefulDisconnect { + get => ReadU1(GRACEFUL_DISCON); + set => WriteU1(GRACEFUL_DISCON, value); + } + + private ArrayPool ArrayPool { get; } = ArrayPool.Shared; + + public PersistentData(string fileName) + : this( + new FileStream( + fileName ?? throw new ArgumentNullException(nameof(fileName)), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.Read + ), + true + ) { } + + public PersistentData(Stream stream, bool ownsStream = false) { + Stream = stream ?? throw new ArgumentNullException(nameof(stream)); + OwnsStream = ownsStream; + if(!stream.CanRead) + throw new ArgumentException("Stream must be readable.", nameof(stream)); + if(!stream.CanSeek) + throw new ArgumentException("Stream must be seekable.", nameof(stream)); + if(!stream.CanWrite) + throw new ArgumentException("Stream must be writable.", nameof(stream)); + + OffsetStart = stream.Position; + + byte[] buffer = ArrayPool.Rent(HEADER_SIZE); + int read; + + try { + read = stream.Read(buffer, 0, HEADER_SIZE); + + if(read > 0) { + if(BitConverter.ToInt32(buffer, 0) != MAGIC) + throw new ArgumentException("Stream does not contain a valid persistent data file structure: invalid magic number.", nameof(stream)); + if(buffer[5] is < 1 or > VERSION) + throw new ArgumentException("Stream does not contain a valid persistent data file structure: unsupported version.", nameof(stream)); + } + } finally { + ArrayPool.Return(buffer); + } + + if(read < 1) { + Stream.Seek(OffsetStart, SeekOrigin.Begin); + Stream.Write(BitConverter.GetBytes(MAGIC)); + // intentionally incompatible with satori + Stream.WriteByte(0); + Stream.WriteByte(VERSION); + // lol + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.WriteByte(0); + Stream.Flush(); + } else if(read < HEADER_SIZE) + throw new ArgumentException("Stream does not contain a valid persistent data file structure: not enough data.", nameof(stream)); + } + + private void Seek(long address) { + Stream.Seek(OffsetStart + address, SeekOrigin.Begin); + } + + private string ReadStr(long address, int length) { + byte[] buffer = ArrayPool.Rent(length); + try { + Seek(address); + Stream.Read(buffer, 0, length); + return Encoding.UTF8.GetString(buffer).Trim('\0'); // retarded + } finally { + ArrayPool.Return(buffer); + } + } + + private bool ReadU1(long address) { + return ReadU8(address) > 0; + } + + private byte ReadU8(long address) { + Seek(address); + int value = Stream.ReadByte(); + return (byte)(value < 1 ? 0 : value); + } + + private long ReadI64(long address) { + byte[] buffer = ArrayPool.Rent(8); + try { + Seek(address); + return Stream.Read(buffer) < 8 ? 0 : BitConverter.ToInt64(buffer); + } finally { + ArrayPool.Return(buffer); + } + } + + private void WriteStr(long address, int length, string value) { + value ??= string.Empty; + if(Encoding.UTF8.GetByteCount(value) > length) + throw new ArgumentException("Value exceeds maximum length."); + + byte[] buffer = Encoding.UTF8.GetBytes(value); + int difference = length - buffer.Length; + + Seek(address); + Stream.Write(Encoding.UTF8.GetBytes(value)); + + while(difference-- > 0) + Stream.WriteByte(0); + } + + private void WriteU1(long address, bool value) { + WriteU8(address, (byte)(value ? 0x35 : 0)); + } + private void WriteU8(long address, byte number) { + Seek(address); + Stream.WriteByte(number); + } + + private void WriteI64(long address, long number) { + Seek(address); + Stream.Write(BitConverter.GetBytes(number)); + } + + public void Flush() { + Stream.Flush(); + } + + private bool IsDisposed; + + ~PersistentData() { + DoDispose(); + } + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Flush(); + + if(OwnsStream) + Stream.Dispose(); + } + } +} diff --git a/Satori/Program.cs b/Satori/Program.cs new file mode 100644 index 0000000..0505241 --- /dev/null +++ b/Satori/Program.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace SockChatKeepAlive { + public static class Program { + public const string PERSIST_FILE = "Persist.dat"; + public const string AUTH_TOKEN = "AuthToken.txt"; + + public const string STORAGE_DIR_NAME = ".sochkeal"; + + public static readonly ManualResetEvent ManualReset = new(false); + + public static string StorageDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), STORAGE_DIR_NAME); + + public static async Task Main() { + Console.WriteLine("Starting Sock Chat Keep Alive..."); + + if(!Directory.Exists(StorageDirectory)) { + Console.WriteLine("Storage directory does not exist, it will be created..."); + Directory.CreateDirectory(StorageDirectory).Attributes |= FileAttributes.Hidden; + } + + string authTokenPath = Path.Combine(StorageDirectory, AUTH_TOKEN); + if(!File.Exists(authTokenPath)) { + File.WriteAllLines(authTokenPath, new[] { + "Misuzu", + "Token goes here", + "Futami shared url goes here" + }); + Console.WriteLine("Auth token file not found! Configure it at:"); + Console.WriteLine(authTokenPath); + return; + } + + string[] getAuthInfo() { return File.ReadAllLines(authTokenPath); }; + + using ManualResetEvent mre = new(false); + bool hasCancelled = false; + + void cancelKeyPressHandler(object sender, ConsoleCancelEventArgs ev) { + Console.CancelKeyPress -= cancelKeyPressHandler; + hasCancelled = true; + ev.Cancel = true; + mre.Set(); + }; + Console.CancelKeyPress += cancelKeyPressHandler; + + if(hasCancelled) return; + + using HttpClient httpClient = new(); + httpClient.DefaultRequestHeaders.Add("Accept-Language", "en-GB,en;q=0.5"); + httpClient.DefaultRequestHeaders.Add("DNT", "1"); + httpClient.DefaultRequestHeaders.Add("User-Agent", "SockChatKeepAlive/20230319 (+https://fii.moe/beans)"); + + if(hasCancelled) return; + + Console.WriteLine("Loading persistent data file..."); + using PersistentData persist = new(Path.Combine(StorageDirectory, PERSIST_FILE)); + + if(hasCancelled) return; + + Console.WriteLine("Loading Futami common..."); + FutamiCommon common = await FutamiCommon.FetchAsync(httpClient, getAuthInfo()[2]); + + if(hasCancelled) return; + + Console.WriteLine("Connecting to Sock Chat server..."); + using SockChatClient client = new(common, persist, getAuthInfo); + client.Connect(); + + mre.WaitOne(); + persist.WasGracefulDisconnect = false; + + Console.WriteLine(@"Bye!"); + } + } +} diff --git a/Satori/Properties/PublishProfiles/FolderProfile.pubxml b/Satori/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..c134074 --- /dev/null +++ b/Satori/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + bin\Release\net6.0\publish\win-x64\ + FileSystem + <_TargetId>Folder + net6.0 + win-x64 + true + true + false + false + + \ No newline at end of file diff --git a/Satori/RNG.cs b/Satori/RNG.cs new file mode 100644 index 0000000..039d43e --- /dev/null +++ b/Satori/RNG.cs @@ -0,0 +1,32 @@ +using System; + +namespace SockChatKeepAlive { + public static class RNG { + private static readonly Random random = new(); + + public static int Next() { + lock(random) + return random.Next(); + } + + public static int Next(int max) { + lock(random) + return random.Next(max); + } + + public static int Next(int min, int max) { + lock(random) + return random.Next(min, max); + } + + public static void NextBytes(byte[] buffer) { + lock(random) + random.NextBytes(buffer); + } + + public static double NextDouble() { + lock(random) + return random.NextDouble(); + } + } +} diff --git a/Satori/SockChatClient.cs b/Satori/SockChatClient.cs new file mode 100644 index 0000000..81ddfa0 --- /dev/null +++ b/Satori/SockChatClient.cs @@ -0,0 +1,276 @@ +using PureWebSockets; +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Threading; + +namespace SockChatKeepAlive { + public sealed class SockChatClient : IDisposable { + private readonly FutamiCommon Common; + private readonly PersistentData Persist; + private readonly Func GetAuthInfo; + private PureWebSocket WebSocket; + private Timer Pinger; + + private readonly object PersistAccess = new(); + + public record ChatUser(string Id, string Name, string Colour); + private Dictionary Users { get; set; } = new(); + public ChatUser MyUser { get; private set; } + + private bool IsDisposed = false; + + private DateTimeOffset LastPong = DateTimeOffset.Now; + + public SockChatClient( + FutamiCommon common, + PersistentData persist, + Func getAuthInfo + ) { + Common = common; + Persist = persist; + GetAuthInfo = getAuthInfo; + } + + ~SockChatClient() { + DoDispose(); + } + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Disconnect(); + } + + public void Connect() { + string server = Common.Servers[RNG.Next(Common.Servers.Length)]; + if(server.StartsWith("//")) + server = "wss:" + server; + Console.WriteLine($"Connecting to {server}..."); + + Disconnect(); + WebSocket = new PureWebSocket(server, new PureWebSocketOptions { + SubProtocols = new[] { "sockchat" }, + }); + WebSocket.OnOpened += WebSocket_OnOpen; + WebSocket.OnClosed += WebSocket_OnClose; + WebSocket.OnMessage += WebSocket_OnMessage; + WebSocket.Connect(); + } + + public void Disconnect() { + MyUser = null; + + if(Pinger != null) { + Pinger.Dispose(); + Pinger = null; + } + + if(WebSocket == null) + return; + + try { + WebSocket.OnOpened -= WebSocket_OnOpen; + WebSocket.OnClosed -= WebSocket_OnClose; + WebSocket.OnMessage -= WebSocket_OnMessage; + WebSocket.Disconnect(); + WebSocket = null; + } catch { } + } + + public void SendMessage(string text) { + if(MyUser == null) + return; + + Send("2", MyUser.Id, text.Replace("\t", " ")); + } + + public void SendMessage(object obj) + => SendMessage(obj.ToString()); + + public void SendPing() { + if(MyUser != null) + Send("0", MyUser.Id); + } + + public void Send(params object[] args) { + WebSocket.Send(string.Join("\t", args)); + } + + private void WebSocket_OnOpen(object sender) { + Console.WriteLine("WebSocket connected."); + Console.WriteLine(); + + string[] authInfo = GetAuthInfo(); + Send("1", authInfo[0] ?? string.Empty, authInfo[1] ?? string.Empty); + } + + private void WebSocket_OnClose(object sender, WebSocketCloseStatus reason) { + Console.WriteLine($"WebSocket disconnected: {reason}"); + Disconnect(); + + if(!IsDisposed) { + lock(PersistAccess) + Persist.WasGracefulDisconnect = false; + + Thread.Sleep(10000); + Connect(); + } + } + + private void WebSocket_OnMessage(object sender, string data) { + string[] args = data.Split('\t'); + if(args.Length < 1) + return; + + switch(args[0]) { + case "0": + TimeSpan pongDiff = DateTimeOffset.Now - LastPong; + LastPong = DateTimeOffset.Now; + lock(PersistAccess) + Persist.SatoriTotalUptime += (long)pongDiff.TotalMilliseconds; + break; + + case "1": + if(MyUser == null) { + if(args[1] == "y") { + Pinger = new Timer(x => SendPing(), null, 0, Common.Ping * 1000); + } else { + Disconnect(); + return; + } + } + + Users[args[2]] = new(args[2], args[3], args[4]); + + if(MyUser == null) { + MyUser = Users[args[2]]; + + lock(PersistAccess) { + if(Persist.WasGracefulDisconnect) + Persist.AFKString = MyUser.Name.StartsWith("<") + ? MyUser.Name[4..MyUser.Name.IndexOf(">_")] + : string.Empty; + else { + string afkString = Persist.AFKString; + if(!string.IsNullOrWhiteSpace(afkString)) + SendMessage($"/afk {afkString}"); + } + } + } else + Console.WriteLine($"!! {args[2]} <{args[3]}> joined."); + break; + + case "2": + Console.Write($":: {{{args[4]}}} [{DateTimeOffset.FromUnixTimeSeconds(long.Parse(args[1])):G}] "); + if(Users.ContainsKey(args[2])) + Console.Write($"{Users[args[2]].Id} <{Users[args[2]].Name}>"); + else + Console.Write("*"); + Console.WriteLine(); + + foreach(string line in args[3].Split("
")) { + Console.Write("> "); + Console.WriteLine(line.Replace('\f', '\t').Trim()); + } + Console.WriteLine(); + break; + + case "3": + Users.Remove(args[1]); + Console.WriteLine($"!! {args[1]} left."); + break; + + case "5": + switch(args[1]) { + case "0": + Users[args[2]] = new(args[2], args[3], args[4]); + Console.WriteLine($"!! {args[2]} <{args[3]}> entered channel."); + break; + + case "1": + Users.Remove(args[2]); + Console.WriteLine($"!! {args[2]} switched channel."); + break; + } + break; + + case "6": + Console.WriteLine($"!! Message {args[1]} was deleted."); + break; + + case "7": + if(args.Length < 2) + break; + + switch(args[1]) { + case "0": + if(args.Length < 3 || !int.TryParse(args[2], out int amount)) + break; + + int offset = 3; + for(int i = 0; i < amount; i++) { + Users[args[offset++]] = new(args[offset], args[offset++], args[offset++]); + offset += 2; + } + break; + + case "1": + Console.Write($":: {{{args[8]}}} [{DateTimeOffset.FromUnixTimeSeconds(long.Parse(args[2])):G}] "); + if(args[3].Equals("-1", StringComparison.Ordinal)) + Console.Write("*"); + else + Console.Write($"{args[3]} <{args[4]}>"); + Console.WriteLine(); + + foreach(string line in args[7].Split("
")) { + Console.Write("> "); + Console.WriteLine(line.Replace('\f', '\t').Trim()); + } + Console.WriteLine(); + break; + } + break; + + case "8": + if(args[1] is "0" or "4") + Console.WriteLine("!! Message list cleared"); + + if(args[1] is "1" or "3" or "4") { + Console.WriteLine("!! User list cleared"); + Users.Clear(); + Users.Add(MyUser.Id, MyUser); + } + + if(args[1] is "2" or "3" or "4") + Console.WriteLine("!! Channel list cleared"); + break; + + case "9": + Console.WriteLine($"!! Kicked from server: {args[1]} {args[2]}"); + break; + + case "10": + Users[args[1]] = new(args[1], args[2], args[3]); + Console.WriteLine($"!! {args[1]} updated: {args[2]}."); + + if(MyUser.Id == args[1]) { + MyUser = Users[args[1]]; + + lock(PersistAccess) + Persist.AFKString = MyUser.Name.StartsWith("<") + ? MyUser.Name[4..MyUser.Name.IndexOf(">_")] + : string.Empty; + } + break; + } + } + } +} diff --git a/Satori/SockChatKeepAlive.csproj b/Satori/SockChatKeepAlive.csproj new file mode 100644 index 0000000..15e2815 --- /dev/null +++ b/Satori/SockChatKeepAlive.csproj @@ -0,0 +1,12 @@ + + + + Exe + net6.0 + + + + + + + diff --git a/SockChatKeepAlive.sln b/SockChatKeepAlive.sln new file mode 100644 index 0000000..911b079 --- /dev/null +++ b/SockChatKeepAlive.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33502.453 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SockChatKeepAlive", "Satori\SockChatKeepAlive.csproj", "{7917878E-6D5F-4793-85E0-2D8201F46EEF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7917878E-6D5F-4793-85E0-2D8201F46EEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7917878E-6D5F-4793-85E0-2D8201F46EEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7917878E-6D5F-4793-85E0-2D8201F46EEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7917878E-6D5F-4793-85E0-2D8201F46EEF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CE6FDC89-9A7B-43B6-895C-CE5F0C4D6087} + EndGlobalSection +EndGlobal