diff --git a/Interception/Certificates/CertificateManager.cs b/Interception/Certificates/CertificateManager.cs index b42c4aa..d486441 100644 --- a/Interception/Certificates/CertificateManager.cs +++ b/Interception/Certificates/CertificateManager.cs @@ -76,6 +76,8 @@ private static async Task LoadOrCreateCaCert(string path) return cert; } + private static readonly string[] RequiredDnsNames = ["do.pishock.com", "ps.pishock.com"]; + private static async Task LoadOrCreateServerCert(string path, X509Certificate2 caCert) { if (File.Exists(path)) @@ -89,7 +91,7 @@ private static async Task LoadOrCreateServerCert(string path, var loaded = X509CertificateLoader.LoadPkcs12(buffer, CertPassword, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); - if (loaded.NotAfter > DateTime.UtcNow.AddDays(30)) + if (loaded.NotAfter > DateTime.UtcNow.AddDays(30) && HasRequiredSans(loaded)) return loaded; loaded.Dispose(); } @@ -107,6 +109,7 @@ private static async Task LoadOrCreateServerCert(string path, var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddDnsName("do.pishock.com"); + sanBuilder.AddDnsName("ps.pishock.com"); sanBuilder.AddIpAddress(IPAddress.Loopback); req.CertificateExtensions.Add(sanBuilder.Build()); @@ -127,6 +130,15 @@ private static async Task LoadOrCreateServerCert(string path, return exported; } + private static bool HasRequiredSans(X509Certificate2 cert) + { + var sanExtension = cert.Extensions.OfType().FirstOrDefault(); + if (sanExtension == null) return false; + + var dnsNames = sanExtension.EnumerateDnsNames().ToHashSet(StringComparer.OrdinalIgnoreCase); + return RequiredDnsNames.All(dnsNames.Contains); + } + private bool CheckCaTrusted() { if (_caCert == null) return false; diff --git a/Interception/HostsFile/HostsFileManager.cs b/Interception/HostsFile/HostsFileManager.cs index f2c2beb..dbe8a2b 100644 --- a/Interception/HostsFile/HostsFileManager.cs +++ b/Interception/HostsFile/HostsFileManager.cs @@ -6,17 +6,22 @@ namespace OpenShock.Desktop.Modules.Interception.HostsFile; public sealed class HostsFileManager { private const string HostsPath = @"C:\Windows\System32\drivers\etc\hosts"; - private const string HostEntry = "127.0.0.1 do.pishock.com"; private const string Marker = "# OpenShock Interception"; + private static readonly string[] HostEntries = + [ + $"127.0.0.1 do.pishock.com {Marker}", + $"127.0.0.1 ps.pishock.com {Marker}" + ]; + public bool IsEnabled { get; private set; } public async Task EnableAsync() { - if (IsEnabled) return; - var line = $"{HostEntry} {Marker}"; - await RunElevatedHostsCommand($"add \"{line}\""); + if (IsEnabled && !NeedsUpdate) return; + await RunElevatedHostsCommand("add"); IsEnabled = true; + NeedsUpdate = false; await FlushDns(); } @@ -28,31 +33,45 @@ public async Task DisableAsync() await FlushDns(); } + public bool NeedsUpdate { get; private set; } + public async Task DetectCurrentState() { try { var content = await File.ReadAllTextAsync(HostsPath); - IsEnabled = content.Contains(Marker); + var hasAny = content.Contains(Marker); + var hasAll = content.Contains("do.pishock.com") && content.Contains("ps.pishock.com"); + IsEnabled = hasAny; + NeedsUpdate = hasAny && !hasAll; } catch { IsEnabled = false; + NeedsUpdate = false; } } private static async Task RunElevatedHostsCommand(string action) { string script; - if (action.StartsWith("add")) + if (action == "add") { - var line = action.Substring(4).Trim(); + // Check each host entry individually so upgrades from the old + // single-entry format pick up the new ps.pishock.com line. + var linesArray = string.Join(",", HostEntries.Select(e => $"'{e}'")); script = string.Join("\n", - $"$line = {line};", + $"$lines = @({linesArray});", $"$hostsPath = '{HostsPath}';", "$content = Get-Content $hostsPath -Raw -ErrorAction SilentlyContinue;", - "if ($content -notmatch 'OpenShock Interception') {", - " Add-Content -Path $hostsPath -Value \"`n$line\" -NoNewline:$false", + "if (-not $content) { $content = '' }", + "$added = $false;", + "foreach ($line in $lines) {", + " $host = ($line -split '\\s+')[1];", + " if ($content -notmatch [regex]::Escape($host)) {", + " Add-Content -Path $hostsPath -Value \"`n$line\" -NoNewline:$false;", + " $added = $true", + " }", "}"); } else @@ -104,4 +123,4 @@ private static async Task FlushDns() // Best-effort DNS flush } } -} \ No newline at end of file +} diff --git a/Interception/InterceptionConfig.cs b/Interception/InterceptionConfig.cs index 349ca2e..3a1a4a0 100644 --- a/Interception/InterceptionConfig.cs +++ b/Interception/InterceptionConfig.cs @@ -1,8 +1,10 @@ +using OpenShock.Desktop.Modules.Interception.Server; + namespace OpenShock.Desktop.Modules.Interception; public sealed class InterceptionConfig { public ushort Port { get; set; } = 443; public bool AutoStart { get; set; } = true; - public Dictionary ShareCodeMappings { get; set; } = new(); -} \ No newline at end of file + public Dictionary ShareCodeMappings { get; set; } = new(); +} diff --git a/Interception/InterceptionService.cs b/Interception/InterceptionService.cs index 5ccbf82..fce0903 100644 --- a/Interception/InterceptionService.cs +++ b/Interception/InterceptionService.cs @@ -35,12 +35,16 @@ public async Task StartAsync() var cert = certManager.ServerCertificate; var operateController = ActivatorUtilities.CreateInstance(serviceProvider); + var psController = ActivatorUtilities.CreateInstance(serviceProvider); + var healthController = new HealthWebApiController(); _server = new WebServer(o => o .WithUrlPrefix($"https://*:{port}/") .WithMode(HttpListenerMode.EmbedIO) .WithCertificate(cert)) .WithWebApi("/api", m => m.WithController(() => operateController)) + .WithWebApi("/PiShock", m => m.WithController(() => psController)) + .WithWebApi("/Health", m => m.WithController(() => healthController)) ; _ = _server.RunAsync(); @@ -60,4 +64,4 @@ public async Task UpdateConfig(Action update) update(Config); await moduleConfig.Save(); } -} \ No newline at end of file +} diff --git a/Interception/Server/DoWebApiController.cs b/Interception/Server/DoWebApiController.cs index 6f1de04..a3a495f 100644 --- a/Interception/Server/DoWebApiController.cs +++ b/Interception/Server/DoWebApiController.cs @@ -56,7 +56,7 @@ public async Task Operate() return; } - if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var shockerId)) + if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping)) { _logger.LogError("Share code not mapped to any shocker: {Code}", request.Code); HttpContext.Response.StatusCode = 404; @@ -65,6 +65,15 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/ return; } + if (mapping.ShockerIds.Count == 0) + { + _logger.LogError("Share code has no shockers configured: {Code}", request.Code); + HttpContext.Response.StatusCode = 404; + await HttpContext.SendStringAsync("Share code has no shockers configured", "text/plain", + Encoding.UTF8); + return; + } + var controlType = request.Op switch { 0 => ControlType.Shock, @@ -73,21 +82,18 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/ _ => ControlType.Vibrate }; - var durationMs = (ushort)Math.Clamp(request.Duration * 1000, 300, 30000); - var intensity = (byte)Math.Clamp(request.Intensity, 1, 100); + var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.EffectiveMinDuration * 1000, mapping.EffectiveMaxDuration * 1000); + var intensity = (byte)Math.Clamp(request.Intensity, mapping.EffectiveMinIntensity, mapping.EffectiveMaxIntensity); if (request.Intensity <= 0) controlType = ControlType.Stop; - var controls = new[] + var controls = mapping.ShockerIds.Select(id => new ShockerControl { - new ShockerControl - { - Id = shockerId, - Type = controlType, - Intensity = intensity, - Duration = durationMs - } - }; + Id = id, + Type = controlType, + Intensity = intensity, + Duration = durationMs + }).ToArray(); var customName = request.Name ?? request.Username ?? "PiShock Interception"; @@ -95,8 +101,8 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/ { await _openShockControl.Control(controls, customName); _logger.LogInformation( - "PiShock Do API: control command: {ControlType} {Intensity}% for {Duration}s on shocker {ShockerId} by {Name}", - controlType, intensity, durationMs / 1000.0, shockerId, customName); + "PiShock Do API: control command: {ControlType} {Intensity}% for {Duration}s on {ShockerCount} shocker(s) by {Name}", + controlType, intensity, durationMs / 1000.0, controls.Length, customName); await HttpContext.SendStringAsync( JsonSerializer.Serialize(new { success = true, message = "Operation Succeeded." }), "application/json", Encoding.UTF8); @@ -137,7 +143,7 @@ public async Task GetShockerInfo() return; } - if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var shockerId)) + if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping)) { _logger.LogError("Share code not mapped to any shocker: {Code}", request.Code); HttpContext.Response.StatusCode = 404; @@ -147,10 +153,10 @@ public async Task GetShockerInfo() var info = new { - clientId = shockerId, + clientId = mapping.ShockerIds.FirstOrDefault(), name = $"Shocker ({request.Code})", - maxIntensity = 100, - maxDuration = 15, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration, online = true }; @@ -158,4 +164,4 @@ await HttpContext.SendStringAsync( JsonSerializer.Serialize(info), "application/json", Encoding.UTF8); } -} \ No newline at end of file +} diff --git a/Interception/Server/HealthWebApiController.cs b/Interception/Server/HealthWebApiController.cs new file mode 100644 index 0000000..ffbc417 --- /dev/null +++ b/Interception/Server/HealthWebApiController.cs @@ -0,0 +1,23 @@ +using System.Text; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; + +namespace OpenShock.Desktop.Modules.Interception.Server; + +public sealed class HealthWebApiController : WebApiController +{ + [Route(HttpVerbs.Get, "/Check")] + public async Task Check() + { + HttpContext.Response.StatusCode = 200; + await HttpContext.SendStringAsync("OK", "text/plain", Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/Server")] + public Task Server() + { + HttpContext.Response.StatusCode = 204; + return Task.CompletedTask; + } +} diff --git a/Interception/Server/PsWebApiController.cs b/Interception/Server/PsWebApiController.cs new file mode 100644 index 0000000..5d81764 --- /dev/null +++ b/Interception/Server/PsWebApiController.cs @@ -0,0 +1,241 @@ +using System.Text; +using System.Text.Json; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Microsoft.Extensions.Logging; +using OpenShock.Desktop.ModuleBase.Api; +using OpenShock.Desktop.ModuleBase.Models; + +namespace OpenShock.Desktop.Modules.Interception.Server; + +public sealed class PsWebApiController : WebApiController +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly ILogger _logger; + private readonly IOpenShockControl _openShockControl; + private readonly IOpenShockData _openShockData; + private readonly InterceptionService _service; + + public PsWebApiController(InterceptionService service, IOpenShockControl openShockControl, + IOpenShockData openShockData, ILogger logger) + { + _service = service; + _openShockControl = openShockControl; + _openShockData = openShockData; + _logger = logger; + } + + [Route(HttpVerbs.Post, "/Operate")] + public async Task Operate() + { + using var reader = new StreamReader(HttpContext.Request.InputStream); + var body = await reader.ReadToEndAsync(); + + PiShockRequest? request; + try + { + request = JsonSerializer.Deserialize(body, JsonOptions); + } + catch + { + _logger.LogError("Error parsing JSON body: {Body}", body); + HttpContext.Response.StatusCode = 400; + await HttpContext.SendStringAsync("Invalid JSON", "text/plain", Encoding.UTF8); + return; + } + + if (request?.Code == null) + { + _logger.LogError("Missing share code in request: {Body}", body); + HttpContext.Response.StatusCode = 400; + await HttpContext.SendStringAsync("Missing share code", "text/plain", Encoding.UTF8); + return; + } + + if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping)) + { + _logger.LogError("Share code not mapped to any shocker: {Code}", request.Code); + await HttpContext.SendStringAsync("This code doesn't exist.", "text/plain", Encoding.UTF8); + return; + } + + if (mapping.ShockerIds.Count == 0) + { + _logger.LogError("Share code has no shockers configured: {Code}", request.Code); + await HttpContext.SendStringAsync("This code doesn't exist.", "text/plain", Encoding.UTF8); + return; + } + + var controlType = request.Op switch + { + 0 => ControlType.Shock, + 1 => ControlType.Vibrate, + 2 => ControlType.Sound, + _ => ControlType.Vibrate + }; + + var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.EffectiveMinDuration * 1000, mapping.EffectiveMaxDuration * 1000); + var intensity = (byte)Math.Clamp(request.Intensity, mapping.EffectiveMinIntensity, mapping.EffectiveMaxIntensity); + + if (request.Intensity <= 0) controlType = ControlType.Stop; + + var controls = mapping.ShockerIds.Select(id => new ShockerControl + { + Id = id, + Type = controlType, + Intensity = intensity, + Duration = durationMs + }).ToArray(); + + var customName = request.Name ?? request.Username ?? "PiShock Interception"; + + try + { + await _openShockControl.Control(controls, customName); + _logger.LogInformation( + "PiShock Ps API Operate: {ControlType} {Intensity}% for {Duration}s on {ShockerCount} shocker(s) by {Name}", + controlType, intensity, durationMs / 1000.0, controls.Length, customName); + await HttpContext.SendStringAsync("Operation Succeeded.", "text/plain", Encoding.UTF8); + } + catch (Exception ex) + { + HttpContext.Response.StatusCode = 500; + await HttpContext.SendStringAsync(ex.Message, "text/plain", Encoding.UTF8); + } + } + + [Route(HttpVerbs.Get, "/GetUserDevices")] + public async Task GetUserDevices() + { + _logger.LogInformation("PiShock Ps API: GetUserDevices request"); + + var shareCodeIndex = 0; + var devices = new List(); + + // Build a synthetic device containing all mapped shockers + var shockers = new List(); + foreach (var (shareCode, mapping) in _service.Config.ShareCodeMappings) + { + foreach (var shockerId in mapping.ShockerIds) + { + var shockerInfo = FindShockerInfo(shockerId); + shockers.Add(new + { + shockerId = shareCodeIndex++, + name = shockerInfo?.Name ?? $"Shocker ({shareCode})", + shareCode, + isPaused = false, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration + }); + } + } + + if (shockers.Count > 0) + { + devices.Add(new + { + deviceId = 0, + name = "OpenShock Interception", + shockers + }); + } + + await HttpContext.SendStringAsync( + JsonSerializer.Serialize(devices), + "application/json", Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/GetShareCodesByOwner")] + public async Task GetShareCodesByOwner() + { + _logger.LogInformation("PiShock Ps API: GetShareCodesByOwner request"); + + var result = new List(); + var shareId = 0; + + foreach (var (shareCode, mapping) in _service.Config.ShareCodeMappings) + { + var shockerInfo = mapping.ShockerIds.Count > 0 ? FindShockerInfo(mapping.ShockerIds[0]) : null; + result.Add(new + { + shareCodeId = shareId++, + code = shareCode, + shockerName = shockerInfo?.Name ?? $"Shocker ({shareCode})", + isPaused = false, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration, + permissions = new + { + shock = true, + vibrate = true, + sound = true + } + }); + } + + await HttpContext.SendStringAsync( + JsonSerializer.Serialize(result), + "application/json", Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/GetShockersByShareIds")] + public async Task GetShockersByShareIds() + { + _logger.LogInformation("PiShock Ps API: GetShockersByShareIds request"); + + var shareIdParams = HttpContext.GetRequestQueryData().GetValues("shareIds") ?? []; + + var result = new List(); + var shareId = 0; + + foreach (var (shareCode, mapping) in _service.Config.ShareCodeMappings) + { + var currentId = shareId++; + if (shareIdParams.Length > 0 && !shareIdParams.Contains(currentId.ToString())) continue; + + var shockerInfo = mapping.ShockerIds.Count > 0 ? FindShockerInfo(mapping.ShockerIds[0]) : null; + result.Add(new + { + shareCodeId = currentId, + code = shareCode, + shockerName = shockerInfo?.Name ?? $"Shocker ({shareCode})", + isPaused = false, + maxIntensity = (int)mapping.EffectiveMaxIntensity, + maxDuration = (int)mapping.EffectiveMaxDuration, + online = true, + permissions = new + { + shock = true, + vibrate = true, + sound = true + } + }); + } + + await HttpContext.SendStringAsync( + JsonSerializer.Serialize(result), + "application/json", Encoding.UTF8); + } + + private ShockerInfo? FindShockerInfo(Guid shockerId) + { + foreach (var hub in _openShockData.Hubs.Value) + { + foreach (var shocker in hub.Shockers) + { + if (shocker.Id == shockerId) + return new ShockerInfo(shocker.Name, hub.Name); + } + } + + return null; + } + + private sealed record ShockerInfo(string Name, string HubName); +} diff --git a/Interception/Server/ShareCodeMapping.cs b/Interception/Server/ShareCodeMapping.cs new file mode 100644 index 0000000..2bf93f8 --- /dev/null +++ b/Interception/Server/ShareCodeMapping.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.Desktop.Modules.Interception.Server; + +public sealed class ShareCodeMapping +{ + public List ShockerIds { get; set; } = []; + public byte MinIntensity { get; set; } = 1; + public byte MaxIntensity { get; set; } = 100; + public ushort MinDuration { get; set; } = 1; + public ushort MaxDuration { get; set; } = 15; + + /// + /// Effective min intensity, guaranteed <= EffectiveMaxIntensity. + /// + [JsonIgnore] + public byte EffectiveMinIntensity => Math.Min(MinIntensity, MaxIntensity); + + /// + /// Effective max intensity, guaranteed >= EffectiveMinIntensity. + /// + [JsonIgnore] + public byte EffectiveMaxIntensity => Math.Max(MinIntensity, MaxIntensity); + + /// + /// Effective min duration (seconds), guaranteed <= EffectiveMaxDuration. + /// + [JsonIgnore] + public ushort EffectiveMinDuration => Math.Min(MinDuration, MaxDuration); + + /// + /// Effective max duration (seconds), guaranteed >= EffectiveMinDuration. + /// + [JsonIgnore] + public ushort EffectiveMaxDuration => Math.Max(MinDuration, MaxDuration); +} diff --git a/Interception/Ui/InterceptionPage.razor b/Interception/Ui/InterceptionPage.razor index 64e8cad..6a476cd 100644 --- a/Interception/Ui/InterceptionPage.razor +++ b/Interception/Ui/InterceptionPage.razor @@ -2,6 +2,7 @@ @using OpenShock.Desktop.ModuleBase.Api @using OpenShock.Desktop.Modules.Interception.Certificates @using OpenShock.Desktop.Modules.Interception.HostsFile +@using OpenShock.Desktop.Modules.Interception.Server PiShock API Interception @@ -28,7 +29,7 @@ Hosts File Redirect - Redirects do.pishock.com to 127.0.0.1 via the system hosts file. + Redirects do.pishock.com and ps.pishock.com to 127.0.0.1 via the system hosts file. Each action requires administrator elevation. @@ -81,43 +82,84 @@ Share Code Mappings - Map PiShock share codes to OpenShock shockers. + Map PiShock share codes to OpenShock shockers. Each mapping can target multiple shockers + and has configurable intensity/duration limits. Share Code - OpenShock Shocker - + OpenShock Shockers + Intensity + Duration + @context.Key - @GetShockerName(context.Value) + @GetShockerNames(context.Value) + @context.Value.MinIntensity–@context.Value.MaxIntensity% + @context.Value.MinDuration–@context.Value.MaxDuration s + - - - - @foreach (var hub in OpenShockData.Hubs.Value) - { - foreach (var shocker in hub.Shockers) + @* Add / Edit Mapping Form *@ + + @(_editingShareCode != null ? "Edit Mapping" : "Add Mapping") + + + + + Guid.TryParse(id, out var g) ? GetShockerName(g) : id)))" + Style="max-width: 500px;"> + @foreach (var hub in OpenShockData.Hubs.Value) { - @hub.Name / @shocker.Name + foreach (var shocker in hub.Shockers) + { + @hub.Name / @shocker.Name + } } - } - - - Add - - + + + + + + + + + + + + @(_editingShareCode != null ? "Save" : "Add") + + @if (_editingShareCode != null) + { + + Cancel + + } + + + @@ -139,7 +181,12 @@ [ModuleInject] public required HostsFileManager HostsManager { get; set; } private string _newShareCode = string.Empty; - private Guid _selectedShockerId; + private HashSet _selectedShockerIds = []; + private byte _minIntensity = 1; + private byte _maxIntensity = 100; + private ushort _minDuration = 1; + private ushort _maxDuration = 15; + private string? _editingShareCode; private async Task OnServerToggle(bool value) { @@ -179,21 +226,66 @@ StateHasChanged(); } - private async Task AddMapping() + private void EditMapping(string shareCode, ShareCodeMapping mapping) { - if (string.IsNullOrWhiteSpace(_newShareCode) || _selectedShockerId == Guid.Empty) return; - await Service.UpdateConfig(c => c.ShareCodeMappings[_newShareCode] = _selectedShockerId); - _newShareCode = string.Empty; - _selectedShockerId = Guid.Empty; + _editingShareCode = shareCode; + _newShareCode = shareCode; + _selectedShockerIds = [.. mapping.ShockerIds]; + _minIntensity = mapping.MinIntensity; + _maxIntensity = mapping.MaxIntensity; + _minDuration = mapping.MinDuration; + _maxDuration = mapping.MaxDuration; + StateHasChanged(); + } + + private void CancelEdit() + { + ResetForm(); + StateHasChanged(); + } + + private async Task SaveMapping() + { + if (string.IsNullOrWhiteSpace(_newShareCode) || _selectedShockerIds.Count == 0) return; + + var mapping = new ShareCodeMapping + { + ShockerIds = _selectedShockerIds.ToList(), + MinIntensity = _minIntensity, + MaxIntensity = _maxIntensity, + MinDuration = _minDuration, + MaxDuration = _maxDuration + }; + + await Service.UpdateConfig(c => c.ShareCodeMappings[_newShareCode] = mapping); + ResetForm(); StateHasChanged(); } private async Task RemoveMapping(string code) { await Service.UpdateConfig(c => c.ShareCodeMappings.Remove(code)); + if (_editingShareCode == code) ResetForm(); StateHasChanged(); } + private void ResetForm() + { + _editingShareCode = null; + _newShareCode = string.Empty; + _selectedShockerIds = []; + _minIntensity = 1; + _maxIntensity = 100; + _minDuration = 1; + _maxDuration = 15; + } + + private string GetShockerNames(ShareCodeMapping mapping) + { + if (mapping.ShockerIds.Count == 0) return "(none)"; + return string.Join(", ", mapping.ShockerIds.Select(GetShockerName)); + } + private string GetShockerName(Guid shockerId) { foreach (var hub in OpenShockData.Hubs.Value)