using JsonFx.Json; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Security.Cryptography; using System.Text; using UnityEngine; namespace ModStatistics { [KSPAddon(KSPAddon.Startup.Instantly, true)] internal class ModStatistics : MonoBehaviour { // The implementation with the highest version number will be allowed to run. private const int version = 7; private static int _version = version; private static readonly string folder; private static readonly string configpath; static ModStatistics() { folder = KSPUtil.ApplicationRootPath + "GameData" + Path.DirectorySeparatorChar + "ModStatistics" + Path.DirectorySeparatorChar; configpath = folder + "settings.cfg"; } public void Start() { // Compatible types are identified by the type name and version field name. int highest = getAllTypes() .Where(t => t.Name == typeof(ModStatistics).Name) .Select(t => t.GetField("_version", BindingFlags.Static | BindingFlags.NonPublic)) .Where(f => f != null) .Where(f => f.FieldType == typeof(int)) .Max(f => (int)f.GetValue(null)); // Let the latest version execute. if (version != highest) { return; } Debug.Log(String.Format("[ModStatistics] Running version {0}", _version)); // Other checkers will see this version and not run. // This accomplishes the same as an explicit "ran" flag with fewer moving parts. _version = int.MaxValue; Directory.CreateDirectory(folder); var node = ConfigNode.Load(configpath); if (node == null) { promptUpdatePref(); } else { var disabledString = node.GetValue("disabled"); if (disabledString != null && bool.TryParse(disabledString, out disabled) && disabled) { Debug.Log("[ModStatistics] Disabled in configuration file"); return; } var idString = node.GetValue("id"); try { id = new Guid(idString); } catch { Debug.LogWarning("[ModStatistics] Could not parse ID"); } var str = node.GetValue("update"); if (str != null && bool.TryParse(str, out update)) { writeConfig(); checkUpdates(); } else { promptUpdatePref(); } } running = true; DontDestroyOnLoad(this); if (File.Exists(folder + "checkpoint.json")) { File.Move(folder + "checkpoint.json", createReportPath()); } sendReports(); install(); } private void promptUpdatePref() { PopupDialog.SpawnPopupDialog( new MultiOptionDialog( "You recently installed a mod which uses ModStatistics to report anonymous usage information. Would you like ModStatistics to automatically update when new versions are available?", new Callback(() => { update = GUILayout.Toggle(update, "Automatically install ModStatistics updates"); }), "ModStatistics", HighLogic.Skin, new DialogOption("OK", () => { writeConfig(); checkUpdates(); }, true), new DialogOption("Launch Website", () => { Application.OpenURL(@"http://stats.majiir.net/"); }, false) ), true, HighLogic.Skin ); } private void writeConfig() { var text = String.Format("// To disable ModStatistics, change the line below to \"disabled = true\"" + Environment.NewLine + "// Do NOT delete the ModStatistics folder. It could be reinstated by another mod." + Environment.NewLine + "disabled = {2}" + Environment.NewLine + "update = {1}" + Environment.NewLine + "id = {0:N}" + Environment.NewLine, id, update.ToString().ToLower(), disabled.ToString().ToLower()); File.WriteAllText(configpath, text); } private static IEnumerable<Type> getAllTypes() { foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { Type[] types; try { types = assembly.GetTypes(); } catch (Exception) { types = Type.EmptyTypes; } foreach (var type in types) { yield return type; } } } private bool running = false; private bool disabled = false; private bool update = true; private Guid id = Guid.NewGuid(); private GameScenes? scene = null; private DateTime started = DateTime.UtcNow; private DateTime sceneStarted = DateTime.UtcNow; private Dictionary<GameScenes, TimeSpan> sceneTimes = new Dictionary<GameScenes, TimeSpan>(); private DateTime nextSave = DateTime.MinValue; public void FixedUpdate() { if (!running) { return; } if (scene != HighLogic.LoadedScene) { updateSceneTimes(); } var now = DateTime.UtcNow; if (nextSave < now) { nextSave = now.AddSeconds(15); var report = prepareReport(true); File.WriteAllText(folder + "checkpoint.json", report); } } public void OnDestroy() { if (!running) { return; } Debug.Log("[ModStatistics] Saving report"); File.WriteAllText(createReportPath(), prepareReport(false)); File.Delete(folder + "checkpoint.json"); } private static string createReportPath() { int i = 0; string path; do { path = folder + "report-" + i + ".json"; i++; } while (File.Exists(path)); return path; } private void sendReports() { var files = Directory.GetFiles(folder, "report-*.json"); using (var client = new WebClient()) { setUserAgent(client); client.Headers.Add(HttpRequestHeader.ContentType, "application/json"); client.UploadStringCompleted += (s, e) => { var file = (string)e.UserState; if (e.Cancelled) { Debug.LogWarning(String.Format("[ModStatistics] Upload operation for {0} was cancelled", Path.GetFileName(file))); } else if (e.Error != null) { Debug.LogError(String.Format("[ModStatistics] Could not upload {0}:\n{1}", Path.GetFileName(file), e.Error)); } else { Debug.Log("[ModStatistics] " + Path.GetFileName(file) + " sent successfully"); File.Delete(file); } }; foreach (var file in files) { try { client.UploadStringAsync(new Uri(@"http://stats.majiir.net/submit_report"), null, File.ReadAllText(file), file); } catch (Exception e) { Debug.LogWarning(String.Format("[ModStatistics] Error initiating {0) upload:\n{1}", Path.GetFileName(file), e)); } } } } private static void setUserAgent(WebClient client) { client.Headers.Add(HttpRequestHeader.UserAgent, String.Format("ModStatistics/{0} ({1})", getInformationalVersion(Assembly.GetExecutingAssembly()), version)); } private class ManifestEntry { public string url = String.Empty; public string path = String.Empty; } private void checkUpdates() { if (!update) { return; } using (var client = new WebClient()) { client.DownloadStringCompleted += (s, e) => { if (e.Cancelled) { Debug.LogWarning(String.Format("[ModStatistics] Update query operation was cancelled")); } else if (e.Error != null) { Debug.LogError(String.Format("[ModStatistics] Could not query for updates:\n{0}", e.Error)); } else { try { var manifest = new JsonReader().Read<ManifestEntry[]>(e.Result); foreach (var entry in manifest) { var dest = folder + Path.DirectorySeparatorChar + entry.path.Replace('/', Path.DirectorySeparatorChar); Directory.CreateDirectory(Path.GetDirectoryName(dest)); setUserAgent(client); client.DownloadFileAsync(new Uri(entry.url), dest, entry); } } catch (Exception ex) { Debug.LogError(String.Format("[ModStatistics] Error parsing update manifest:\n{0}", ex)); } } }; client.DownloadFileCompleted += (s, e) => { var entry = e.UserState as ManifestEntry; if (e.Cancelled) { Debug.LogWarning(String.Format("[ModStatistics] Update download operation was cancelled")); } else if (e.Error != null) { Debug.LogError(String.Format("[ModStatistics] Could not download update for {0}:\n{1}", entry.path, e.Error)); } else { Debug.Log("[ModStatistics] Successfully updated " + entry.path); } }; setUserAgent(client); client.DownloadStringAsync(new Uri(@"http://stats.majiir.net/update")); } } private void install() { var dest = folder + "Plugins" + Path.DirectorySeparatorChar; Directory.CreateDirectory(dest); if (!File.Exists(dest + "JsonFx.dll")) { var fxpath = AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == "JsonFx").Location; File.Copy(fxpath, dest + "JsonFx.dll"); } var mspath = dest + "ModStatistics-" + getInformationalVersion(Assembly.GetExecutingAssembly()) + ".dll"; if (!File.Exists(mspath)) { File.Copy(Assembly.GetExecutingAssembly().Location, mspath); } } private void updateSceneTimes() { var lastScene = scene; var lastStarted = sceneStarted; scene = HighLogic.LoadedScene; sceneStarted = DateTime.UtcNow; if (lastScene == null) { return; } if (!sceneTimes.ContainsKey(lastScene.Value)) { sceneTimes[lastScene.Value] = TimeSpan.Zero; } sceneTimes[lastScene.Value] += (sceneStarted - lastStarted); } private object[] assembliesInfo = null; private string prepareReport(bool crashed) { updateSceneTimes(); if (assembliesInfo == null) { assembliesInfo = (from assembly in AssemblyLoader.loadedAssemblies.Skip(1) let fileVersion = assembly.assembly.GetName().Version select new { dllName = assembly.dllName, name = assembly.name, title = getAssemblyTitle(assembly.assembly), url = assembly.url, sha2 = getAssemblyHash(assembly.assembly), kspVersionMajor = assembly.versionMajor, kspVersionMinor = assembly.versionMinor, fileVersion = new { major = fileVersion.Major, minor = fileVersion.Minor, revision = fileVersion.Revision, build = fileVersion.Build, }, informationalVersion = getInformationalVersion(assembly.assembly), }).ToArray(); } var report = new { started = started, finished = sceneStarted, crashed = crashed, statisticsVersion = version, platform = getRunningPlatform(), id = id.ToString("N"), installedWithSteam = installedWithSteam(), gameVersion = new { build = Versioning.BuildID, major = Versioning.version_major, minor = Versioning.version_minor, revision = Versioning.Revision, experimental = Versioning.Experimental, isBeta = Versioning.isBeta, isSteam = Versioning.IsSteam, is64 = IntPtr.Size == 8, }, scenes = sceneTimes.OrderBy(p => p.Key).ToDictionary(p => p.Key.ToString().ToLower(), p => p.Value.TotalMilliseconds), systemInfo = new { cpus = SystemInfo.processorCount, gpuMemory = SystemInfo.graphicsMemorySize, gpuVendorId = SystemInfo.graphicsDeviceVendorID, systemMemory = SystemInfo.systemMemorySize, }, assemblies = assembliesInfo, }; return new JsonWriter().Write(report); } private static string getInformationalVersion(Assembly assembly) { return System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location).ProductVersion; } private static HashSet<String> warnedAssemblies = new HashSet<String>(); private static string getAssemblyTitle(Assembly assembly) { try { var attr = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false).OfType<AssemblyTitleAttribute>().FirstOrDefault(); if (attr == null) { return String.Empty; } return attr.Title; } catch (TypeLoadException e) { var name = assembly.GetName().Name; if (!warnedAssemblies.Contains(name)) { warnedAssemblies.Add(name); Debug.LogError(String.Format("[ModStatistics] Error while inspecting assembly {0}. This probably means that {0} is targeting a runtime other than .NET 3.5. Please notify the author of {0} of this error.\n\n{1}", name, e)); } return null; } } private enum Platform { Windows, Linux, Mac } private static Platform getRunningPlatform() { var platform = Environment.OSVersion.Platform; if (platform == PlatformID.Unix) { if (Directory.Exists("/Applications") && Directory.Exists("/Users") && Directory.Exists("/Volumes") && Directory.Exists("/System")) { return Platform.Mac; } else { return Platform.Linux; } } else if (platform == PlatformID.MacOSX) { return Platform.Mac; } else { return Platform.Windows; } } private static string getAssemblyHash(Assembly assembly) { byte[] hash; using (var sha2 = SHA256.Create()) { using (var stream = File.OpenRead(assembly.Location)) { hash = sha2.ComputeHash(stream); } } var sb = new StringBuilder(); foreach (var b in hash) { sb.Append(b.ToString("X2")); } return sb.ToString(); } private static bool installedWithSteam() { var path = KSPUtil.ApplicationRootPath; return path.Contains(@"SteamApps\common") || path.Contains(@"SteamApps/common"); } } }