Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions Desktop/Backend/BackendHubManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,35 @@ private Task OnShockerLogHandler(LogEventArgs logEventArgs)
/// <returns></returns>
public Task Control(IEnumerable<Control> shocks, string? customName = null)
{
var enabledShockers = _configManager.Config.OpenShock.Shockers
.Where(y => y.Value.Enabled &&
_openShockApi.Hubs.Value.Any(x=>
x.Shockers.Any(z => z.Id == y.Key && !z.IsPaused)))
.Select(x => x.Key)
.ToHashSet();

var shocksToSend = shocks.Where(x => enabledShockers.Contains(x.Id));
var shocksToSend = shocks.Where(x => CanControl(x.Id, x.Type));
return _openShockHubClient.Control(shocksToSend, customName);
}

/// <summary>
/// Whether the given shocker may be controlled with the given control type: it must be enabled in config, exist and
/// not be paused, and for shared shockers we must hold the permission matching the control type. Owned shockers are
/// not present in the shared permission map and are always allowed.
/// </summary>
private bool CanControl(Guid shockerId, ControlType type)
{
if (!_configManager.Config.OpenShock.Shockers.TryGetValue(shockerId, out var conf) || !conf.Enabled)
return false;

if (!_openShockApi.ShockerLookup.TryGetValue(shockerId, out var location) || location.Shocker.IsPaused)
return false;

if (!_openShockApi.SharedShockerPermissions.TryGetValue(shockerId, out var permissions)) return true;

return type switch
{
// Stop is always permitted: it can only ever end an action, so it needs no grant of its own.
ControlType.Stop => true,
ControlType.Shock => permissions.Shock,
ControlType.Vibrate => permissions.Vibrate,
ControlType.Sound => permissions.Sound,
_ => false
};
}
}

public readonly struct ShockerLogEventArgs
Expand Down
197 changes: 173 additions & 24 deletions Desktop/Backend/OpenShockApi.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using OpenShock.Desktop.Config;
using OpenShock.Desktop.Models;
using OpenShock.Desktop.Models.BaseImpl;
using OpenShock.Desktop.ModuleBase.StableInterfaces;
using OpenShock.Desktop.Utils;
using OpenShock.SDK.CSharp;
using OpenShock.SDK.CSharp.Models;

namespace OpenShock.Desktop.Backend;

Expand Down Expand Up @@ -36,50 +38,197 @@ public void SetupApiClient()
}

public ObservableVariable<IReadOnlyList<IOpenShockHub>> Hubs { get; } = new(ImmutableArray<OpenShockHub>.Empty);

/// <summary>
/// Hubs owned by other users that have shared one or more shockers with us. Kept separate from <see cref="Hubs"/>.
/// Flattened across all owners; see <see cref="SharedOwners"/> for the owner-grouped view used by the UI.
/// </summary>
public ObservableVariable<IReadOnlyList<IOpenShockHub>> SharedHubs { get; } = new(ImmutableArray<OpenShockHub>.Empty);

/// <summary>
/// Shared hubs grouped by the owner that shared them, so the UI can attribute hubs to the person that owns them.
/// </summary>
public ObservableVariable<IReadOnlyList<SharedHubOwner>> SharedOwners { get; } =
new(ImmutableArray<SharedHubOwner>.Empty);

/// <summary>
/// Permissions granted to us per shared shocker. Owned shockers are not present here (they have all permissions).
/// </summary>
public IReadOnlyDictionary<Guid, ShockerPermissions> SharedShockerPermissions => _sharedShockerPermissions;
private volatile IReadOnlyDictionary<Guid, ShockerPermissions> _sharedShockerPermissions =
new Dictionary<Guid, ShockerPermissions>();

/// <summary>
/// All hubs we can control, both owned and shared.
/// </summary>
public IEnumerable<IOpenShockHub> AllHubs => Hubs.Value.Concat(SharedHubs.Value);

/// <summary>
/// Lookup from shocker id to that shocker and the hub it lives on, across owned and shared hubs. Rebuilt whenever
/// a hub list is refreshed, so hot paths do not have to scan every hub per shocker - live control resolves this
/// for every shocker on every frame.
/// </summary>
public IReadOnlyDictionary<Guid, ShockerLocation> ShockerLookup => _shockerLookup;
private volatile IReadOnlyDictionary<Guid, ShockerLocation> _shockerLookup =
new Dictionary<Guid, ShockerLocation>();

public ConcurrentDictionary<Guid, HubStatus> HubStates { get; } = new();


/// <summary>
/// Whether owned / shared hubs have been fetched at least once since the last logout. The shocker config is only
/// pruned once both halves are known, otherwise the half that has not loaded yet would look like it no longer
/// exists and its entries (including the user's enabled choices) would be dropped.
/// </summary>
private bool _ownHubsLoaded;
private bool _sharedHubsLoaded;

/// <summary>
/// Guards the read-modify-write of the shocker config, which is reachable from concurrent refreshes.
/// </summary>
private readonly Lock _shockerConfigLock = new();

/// <summary>
/// Refreshes owned and shared hubs together and syncs the shocker config once, after both are known. Prefer this
/// over calling the individual refreshes back to back, which would sync against a half-populated hub list.
/// </summary>
public async Task RefreshAllHubs()
{
var ownFetched = await FetchOwnHubs();
var sharedFetched = await FetchSharedHubs();

if (ownFetched || sharedFetched) SyncShockerConfig();
}

public async Task RefreshHubs()
{
if (await FetchOwnHubs()) SyncShockerConfig();
}

public async Task RefreshSharedHubs()
{
if (await FetchSharedHubs()) SyncShockerConfig();
}

/// <returns>Whether the hub list was updated and the shocker config needs syncing.</returns>
private async Task<bool> FetchOwnHubs()
{
if (Client == null)
{
_logger.LogError("Client is not initialized!");
throw new Exception("Client is not initialized!");
}
var response = await Client.GetOwnShockers();
response.Switch(success =>

return response.Match(success =>
{
Hubs.Value = [..success.Value.Select(x => x.ToSdkHub(this))];

// re-populate config with previous data if present, this also deletes any shockers that are no longer present
var shockerList = new Dictionary<Guid, OpenShockConf.ShockerConf>();
foreach (var shocker in success.Value.SelectMany(x => x.Shockers))
{
var enabled = true;

if (_configManager.Config.OpenShock.Shockers.TryGetValue(shocker.Id, out var confShocker))
{
enabled = confShocker.Enabled;
}

shockerList.Add(shocker.Id, new OpenShockConf.ShockerConf
{
Enabled = enabled
});
}
_configManager.Config.OpenShock.Shockers = shockerList;
_configManager.Save();
_ownHubsLoaded = true;
RebuildShockerLookup();
return true;
},
error =>
_ =>
{
_logger.LogError("We are not authenticated with the OpenShock API!");
// TODO: handle unauthenticated error
return false;
});
}

/// <returns>Whether the shared hub list was updated and the shocker config needs syncing.</returns>
private async Task<bool> FetchSharedHubs()
{
if (Client == null)
{
_logger.LogError("Client is not initialized!");
throw new Exception("Client is not initialized!");
}
var response = await Client.GetSharedShockers();

return response.Match(success =>
{
var owners = success.Value.ToSdkSharedOwners(this);
SharedOwners.Value = owners;
SharedHubs.Value = owners.SelectMany(owner => owner.Hubs).ToArray();

_sharedShockerPermissions = success.Value
.SelectMany(owner => owner.Devices)
.SelectMany(device => device.Shockers)
.ToDictionary(shocker => shocker.Id, shocker => shocker.Permissions);

_sharedHubsLoaded = true;
RebuildShockerLookup();
return true;
},
_ =>
{
_logger.LogError("We are not authenticated with the OpenShock API!");
// TODO: handle unauthenticated error
return false;
});
}

private void RebuildShockerLookup()
{
var lookup = new Dictionary<Guid, ShockerLocation>();

foreach (var hub in AllHubs)
foreach (var shocker in hub.Shockers)
lookup.TryAdd(shocker.Id, new ShockerLocation(hub, shocker));

_shockerLookup = lookup;
}

/// <summary>
/// Re-populates the per-shocker config with the currently known owned and shared shockers, preserving the enabled
/// flag for shockers that were already present and dropping shockers that no longer exist. Newly discovered owned
/// shockers default to enabled; newly discovered shared shockers default to disabled so another user's device is
/// never controlled until the user explicitly opts in.
///
/// Entries are only dropped once both the owned and the shared hub list have been fetched; until then unknown
/// entries are carried over untouched so a partially loaded state cannot discard the user's choices.
/// </summary>
private void SyncShockerConfig()
{
lock (_shockerConfigLock)
{
var existing = _configManager.Config.OpenShock.Shockers;
var shockerList = new Dictionary<Guid, OpenShockConf.ShockerConf>();

foreach (var shockerId in AllHubs.SelectMany(x => x.Shockers).Select(x => x.Id))
{
if (shockerList.ContainsKey(shockerId)) continue;

// Shared shockers are present in the permission map; owned shockers are not.
var enabled = !_sharedShockerPermissions.ContainsKey(shockerId);
if (existing.TryGetValue(shockerId, out var confShocker))
enabled = confShocker.Enabled;

shockerList.Add(shockerId, new OpenShockConf.ShockerConf
{
Enabled = enabled
});
}

if (!_ownHubsLoaded || !_sharedHubsLoaded)
{
foreach (var (shockerId, confShocker) in existing)
shockerList.TryAdd(shockerId, confShocker);
}

_configManager.Config.OpenShock.Shockers = shockerList;
_configManager.Save();
}
}

public void Logout()
{
Hubs.Value = ImmutableArray<OpenShockHub>.Empty;
SharedHubs.Value = ImmutableArray<OpenShockHub>.Empty;
SharedOwners.Value = ImmutableArray<SharedHubOwner>.Empty;
_sharedShockerPermissions = new Dictionary<Guid, ShockerPermissions>();
_shockerLookup = new Dictionary<Guid, ShockerLocation>();
_ownHubsLoaded = false;
_sharedHubsLoaded = false;
}

}
15 changes: 15 additions & 0 deletions Desktop/Models/SharedHubOwner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using OpenShock.Desktop.ModuleBase.StableInterfaces;

namespace OpenShock.Desktop.Models;

/// <summary>
/// A user who has shared one or more of their hubs' shockers with the current user, grouped for display so the UI can
/// attribute shared hubs to the person that owns them.
/// </summary>
public sealed class SharedHubOwner
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public Uri? Image { get; init; }
public required IReadOnlyList<IOpenShockHub> Hubs { get; init; }
}
9 changes: 9 additions & 0 deletions Desktop/Models/ShockerLocation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using OpenShock.Desktop.ModuleBase.StableInterfaces;

namespace OpenShock.Desktop.Models;

/// <summary>
/// A shocker together with the hub it lives on, so a shocker id can be resolved to both in one lookup instead of
/// scanning every hub.
/// </summary>
public sealed record ShockerLocation(IOpenShockHub Hub, IOpenShockShocker Shocker);
1 change: 1 addition & 0 deletions Desktop/ModuleManager/Implementation/OpenShockData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ namespace OpenShock.Desktop.ModuleManager.Implementation;
public class OpenShockData : IOpenShockData
{
public required IObservableVariable<IReadOnlyList<IOpenShockHub>> Hubs { get; init; }
public required IObservableVariable<IReadOnlyList<IOpenShockHub>> SharedHubs { get; init; }
}
3 changes: 2 additions & 1 deletion Desktop/ModuleManager/Implementation/OpenShockService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public OpenShockService(IServiceProvider serviceProvider)
Control = _controlInstance = new OpenShockControl(backendHubManager, liveControlManager);
Data = new OpenShockData
{
Hubs = openShockApi.Hubs
Hubs = openShockApi.Hubs,
SharedHubs = openShockApi.SharedHubs
};
Api = new OpenShockApiWrapper(openShockApi);
Auth = new OpenShockAuth
Expand Down
2 changes: 1 addition & 1 deletion Desktop/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public async Task Authenticate()
await _hubClient.StartAsync();

_logger.LogInformation("Refreshing shockers");
await _apiClient.RefreshHubs();
await _apiClient.RefreshAllHubs();

await _liveControlManager.RefreshConnections();

Expand Down
Loading
Loading