Skip to content
Shiny.NET

Universal Hubs

GitHub GitHub stars for shinyorg/universalhubs
Shared Protocol NuGet downloads for Shiny.UniversalHubs
Host NuGet downloads for Shiny.UniversalHubs.Host
Client NuGet downloads for Shiny.UniversalHubs.Client
Frameworks
.NET
.NET MAUI
Operating Systems
Android
iOS
macOS

Universal Hubs serves BluetoothLE Hubs over Wi-Fi as well as Bluetooth LE. Write the hub once. Players on a plane connect over BLE. At home they connect over Wi-Fi, which has more range and bandwidth and no MTU. Both kinds of client share the same hub, groups and pushes.

The Wi-Fi side is a Shiny HTTP Server Switchboard (SSE + POST, with resume), advertised over mDNS. The hub itself is a plain BluetoothLE Hubs hub: the same contract, source generated proxy, Clients, Groups and IHubContext<THub>. Universal Hubs only carries the calls.

  • One hub, two transports. Clients.All, groups, IHubContext<THub>, MaxClients, ValidateClient and Context.Abort cover BLE and Wi-Fi clients together.
  • One discovery list. BLE scanning and mDNS browsing are merged, so a host seen both ways shows up once.
  • Wi-Fi first, BLE fallback. Connect prefers Wi-Fi and falls back to BLE. The generated proxy, events, Disconnected and file methods work the same on either transport.
  • Knows the transport. UniversalHub<T> hooks get a HubConnection that says which transport the client is on.
  • Resume. A short Wi-Fi drop keeps the connection id and groups, and missed pushes are replayed. The hub sees no disconnect.
  • One hub at a time. Run an always-on hub beside one that only runs on demand.
  • Files: L2CAP over BLE, plain HTTP over Wi-Fi, behind the same UploadFile / DownloadFile.
  • Tunnels. Hubs can opt in to being reachable from the internet.
  • AOT and trim safe, with no reflection.
  1. Install the packages. The host app needs Host, the client app needs Client, and an app that can play either role installs both. The shared protocol package comes along automatically.

    Terminal window
    dotnet add package Shiny.UniversalHubs.Host
    dotnet add package Shiny.UniversalHubs.Client
  2. Describe the hub in an interface both sides can see. It is a normal BluetoothLE Hubs contract:

    using Shiny.BluetoothLE.Hubs;
    [BleHubClient]
    public interface IChatHub
    {
    Task<bool> Send(string message); // client -> host
    event Action<string, string> Received; // host -> clients (who, message)
    }
  3. Implement it on the host by deriving from UniversalHub<TContract>:

    public class ChatHub : UniversalHub<IChatHub>
    {
    public async Task<bool> Send(string message)
    {
    // this.Connection.Transport says whether the caller is on Ble or Http
    await this.Clients.All.Received(this.Context.Client.Name ?? "?", message);
    return true;
    }
    }
  4. Register everything in MauiProgram. Each hub has a BLE characteristic UUID and an mDNS service type, and the client uses the same two:

    // host - BLE (GATT) and Wi-Fi (Switchboard + mDNS) at once
    builder.Services.AddUniversalServer(server => server
    .ServiceUuid(ServiceUuid) // optional - the one BLE service; clients use the same
    .AddHub<ChatHub>(ChatCharacteristicUuid, "_mychat._tcp"));
    // client
    builder.Services.AddUniversalHubClient<IChatHub>(ChatCharacteristicUuid, "_mychat._tcp", o => o.ServiceUuid = ServiceUuid);
  5. Start hosting on one device:

    // IUniversalHubHost host - BLE, Wi-Fi or both, whichever this device can do right now
    HubTransports running = await host.Start();
  6. Discover, connect and call it from another:

    // IUniversalHubClient<IChatHub> client
    client.Hub.Received += (who, message) => Messages.Add($"{who}: {message}");
    var host = await client.DiscoverAll().FirstAsync(x => x.Transports != HubTransports.None);
    await client.Connect(host, new HubConnectOptions("Allan")); // Wi-Fi when it can, BLE otherwise
    await client.Hub.Send($"hello over {client.Transport}!");

iOS (Platforms/iOS/Info.plist). List every hub’s mDNS service type in NSBonjourServices, on both the host and the client:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Bluetooth is used to talk to nearby devices</string>
<key>NSLocalNetworkUsageDescription</key>
<string>The local network is used to talk to nearby devices</string>
<key>NSBonjourServices</key>
<array>
<string>_mychat._tcp</string>
</array>
<!-- clients: hubs on the local network are plain HTTP to a private address -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>

Android (Platforms/Android/AndroidManifest.xml). The BLE permissions from BluetoothLE Hubs, plus:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- clients: hubs on the local network are plain HTTP -->
<application android:usesCleartextTraffic="true" ... />

BLE stacks. On Android, iOS and Mac Catalyst the BLE hosting and client stacks are registered for you. On plain net10.0 (Linux, tests, macOS AppKit), register an IBleHostingManager / IBleManager yourself.

Feature iOS macOS / Mac Catalyst Android
Host over BLE Foreground Yes Yes
Host over Wi-Fi Foreground Yes Yes
Connect as a client Yes Yes Yes
File transfers Yes Yes Wi-Fi, or L2CAP on API 29+
  • Plane, hotel and public Wi-Fi usually isolate clients from each other. mDNS and device-to-device traffic are blocked there, which is why BLE is the fallback.
  • Hosting runs while the app is in the foreground, the same as BLE hosting.
  • The host needs a BLE hosting stack registered even if Bluetooth is off. Wi-Fi-only hosting works on a device whose Bluetooth is off or unavailable.
  • Hub values travel as JSON over Wi-Fi. They are re-framed, never re-serialized, so IBleHubSerializer must produce JSON (the default does).
  • An mDNS TXT record fits about six hubs per host.

The sample is the BluetoothLE Hubs tic-tac-toe game hosting over both transports, plus an always-on chat, for iOS, Android and macOS:

  • Each hosted game runs only while hosting (StartHub / StopHub<GameHub>). The join list shows how each game is reachable, and the game screen says which transport is in use.
  • The chat hub starts with the app and stays open. “Share on the internet” starts a quick tunnel, which only the chat hub allows.
  • Hosting: AddUniversalServer, UniversalHub<T>, start/stop, tunnels, disconnect reasons
  • Connecting Clients: discovery, transport choice, resume, connecting by address, files
  • How It Works: the Wi-Fi wire protocol, mDNS discovery and file routes
claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-universal-hubs Skill