Skip to content

Commit 75f7ad4

Browse files
committed
tvOS support
1 parent 1ae6162 commit 75f7ad4

17 files changed

Lines changed: 602 additions & 13 deletions

File tree

‎Directory.Packages.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
versioned and released independently of this one. 5.4.0 is the floor because it is the
4949
first release carrying IMdnsManager's publish path and ShinyAndroidForegroundService in
5050
the shapes these packages build on. -->
51-
<PackageVersion Include="Shiny.Core" Version="5.4.0"/>
51+
<PackageVersion Include="Shiny.Core" Version="5.6.0-beta-0074"/>
5252
<PackageVersion Include="Shiny.Net.Discovery" Version="5.4.0"/>
5353

5454
<PackageVersion Include="Shiny.DocumentDb" Version="13.2.1"/>

‎Shiny.Net.HttpServer.slnx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<Project Path="samples/Sample.Relay/Sample.Relay.csproj" />
2121
<Project Path="samples/Sample.Maui/Sample.Maui.csproj" />
2222
<Project Path="samples/Sample.Mcp/Sample.Mcp.csproj" />
23+
<Project Path="samples/Sample.TvOS/Sample.TvOS.csproj" />
2324
</Folder>
2425

2526
<Project Path="src/Shiny.Net.HttpServer.AzureRelay/Shiny.Net.HttpServer.AzureRelay.csproj" />

‎readme.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
ASP.NET Core is heavyweight and does not run on .NET MAUI or in several embedded server scenarios.
77
This is a dependency-light, fully AOT/trim-clean HTTP/1.1, HTTP/2 & HTTP/3 server that runs anywhere
8-
.NET runs — plus tunnelling so a server embedded in a phone app is reachable from the public internet.
8+
.NET runs — including .NET MAUI and native tvOS — plus tunnelling so a server embedded in a phone app
9+
is reachable from the public internet.
910

1011
Only `Microsoft.Extensions.*` abstractions are taken as dependencies. Everything else — JSON, crypto,
1112
JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
@@ -24,9 +25,9 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
2425
| [Shiny.Net.HttpServer.WebDav](https://www.nuget.org/packages/Shiny.Net.HttpServer.WebDav) | A WebDAV (RFC 4918) class 1 & 2 server over a directory — mount an app's storage in Finder, Windows Explorer or any WebDAV client, and open the same URL in a browser for a file manager with upload, rename and delete |
2526
| [Shiny.Net.HttpServer.Grpc](https://www.nuget.org/packages/Shiny.Net.HttpServer.Grpc) | gRPC and gRPC-Web — unary, streaming and bidirectional methods over the same HTTP/2 stack, with serialization you supply |
2627
| [Shiny.Net.HttpServer.Discovery](https://www.nuget.org/packages/Shiny.Net.HttpServer.Discovery) | mDNS/DNS-SD (Bonjour) — advertises the server on the local link and finds the ones other devices advertise, so nobody has to type an IP address. The advertisement is held through a restart rather than blinking out and back, and a registration the responder refuses is retried before it is reported |
27-
| [Shiny.Net.HttpServer.Mobile](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mobile) | Mobile lifecycle on Shiny.Core — stop on background and start on resume, an Android foreground service that follows the server's own running state to keep serving, an iOS resume that restarts the server the suspension took, rebind when the device changes network with a bounded retry for the bind a half-up network refuses, and a check for the manifest entries that silently break local networking. iOS and Android, with or without MAUI |
28+
| [Shiny.Net.HttpServer.Mobile](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mobile) | Mobile lifecycle on Shiny.Core — stop on background and start on resume, an Android foreground service that follows the server's own running state to keep serving, an iOS resume that restarts the server the suspension took, rebind when the device changes network with a bounded retry for the bind a half-up network refuses, and a check for the manifest or bundle entries that silently break local networking. iOS and Android, with or without MAUI |
2829
| [Shiny.Net.HttpServer.Testing](https://www.nuget.org/packages/Shiny.Net.HttpServer.Testing) | An `HttpClient` wired to the server through memory — endpoint tests with no port, no listener and no socket, but the real parser, router and middleware |
29-
| [Shiny.Net.HttpServer.Tunnels](https://www.nuget.org/packages/Shiny.Net.HttpServer.Tunnels) | Agent-backed tunnels — supervises `cloudflared`, `ngrok` or `tailscale` and reports the public URL. Desktop, server and CLI; on a phone use the SSH provider or the relay |
30+
| [Shiny.Net.HttpServer.Tunnels](https://www.nuget.org/packages/Shiny.Net.HttpServer.Tunnels) | Agent-backed tunnels — supervises `cloudflared`, `ngrok` or `tailscale` and reports the public URL. Desktop, server and CLI. iOS and tvOS forbid process creation, so the agents throw there with a message pointing at the SSH provider or the relay |
3031
| [Shiny.Net.HttpServer.CommandLine](https://www.nuget.org/packages/Shiny.Net.HttpServer.CommandLine) | A .NET tool — `shinyhttpserver` — that serves a directory over WebDAV, so one address is both a browser file manager (browse, upload, rename, delete) and a drive Finder or Explorer can mount, with basic auth, per-operation permissions, and a QR code in the banner so a phone can scan its way in — `--tunnel` swaps the LAN address for a public pinggy.io tunnel so the phone need not be on the same network |
3132

3233
## Getting Started

‎samples/Sample.TvOS/AppDelegate.cs‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
using System.Net;
2+
using Foundation;
3+
using Shiny.Net.HttpServer;
4+
using UIKit;
5+
6+
namespace Sample.TvOS;
7+
8+
/// <summary>
9+
/// The whole sample: a server on the LAN that follows the app to the foreground.
10+
/// <para>
11+
/// tvOS suspends a backgrounded app exactly as iOS does, and there is no background mode that
12+
/// legitimately holds a listener open on either. So the useful behaviour is not "keep serving" -
13+
/// that is not on offer - it is to be serving again by the time anyone is looking at the app, and
14+
/// to make the fact that it stopped visible rather than leaving a listener that reports itself
15+
/// running with nothing behind it.
16+
/// </para>
17+
/// <para>
18+
/// The lifecycle is wired to UIKit by hand here. <c>Shiny.Net.HttpServer.Mobile</c> does exactly
19+
/// this and rather more - a bounded retry for the bind a half-woken network refuses, a rebind when
20+
/// the device changes network - but it does not target tvOS yet, because Shiny.Core does not. When
21+
/// it does, the two overrides below become <c>AddHttpServerLifecycle()</c> and this file gets
22+
/// shorter. The core server needed no changes for any of this: it is plain <c>net10.0</c>, and a
23+
/// tvOS app references it the way anything else does.
24+
/// </para>
25+
/// </summary>
26+
[Register(nameof(AppDelegate))]
27+
public class AppDelegate : UIApplicationDelegate
28+
{
29+
HttpServer? server;
30+
ServerViewController? controller;
31+
32+
public override UIWindow? Window { get; set; }
33+
34+
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
35+
{
36+
this.controller = new ServerViewController();
37+
38+
// The window-and-delegate lifecycle rather than UIWindowScene. It is obsoleted from
39+
// tvOS 26 and still works, and the scene-based equivalent is a manifest entry, a scene
40+
// delegate and a configuration dictionary - all of it ceremony that would bury the six
41+
// lines this sample is actually about. A real app should adopt scenes.
42+
#pragma warning disable CA1422
43+
this.Window = new UIWindow(UIScreen.MainScreen.Bounds)
44+
{
45+
RootViewController = this.controller
46+
};
47+
#pragma warning restore CA1422
48+
this.Window.MakeKeyAndVisible();
49+
50+
this.server = BuildServer();
51+
52+
return true;
53+
}
54+
55+
// Not FinishedLaunching: a cold start goes through here too, so starting in one place covers
56+
// both the first foreground and every one after it.
57+
public override void WillEnterForeground(UIApplication application) => _ = this.StartAsync();
58+
59+
public override void OnActivated(UIApplication application)
60+
{
61+
// WillEnterForeground is not raised on the very first activation of a cold start.
62+
if (this.server is { IsRunning: false })
63+
_ = this.StartAsync();
64+
}
65+
66+
public override void DidEnterBackground(UIApplication application) => _ = this.StopAsync();
67+
68+
static HttpServer BuildServer()
69+
{
70+
var builder = HttpServer.CreateBuilder();
71+
72+
builder.Configure(o =>
73+
{
74+
// Any, not Loopback: the point is to be reachable from the other devices on the
75+
// network. A TV has no browser to test a loopback listener with anyway.
76+
o.Address = IPAddress.Any;
77+
o.Port = 8080;
78+
});
79+
80+
var server = builder.Build();
81+
82+
server.MapGet("/", _ => Results.Text(StatusPage(), "text/html; charset=utf-8"));
83+
server.MapGet("/ping", _ => Results.Text("pong"));
84+
85+
return server;
86+
}
87+
88+
async Task StartAsync()
89+
{
90+
if (this.server is not { IsRunning: false } target)
91+
return;
92+
93+
try
94+
{
95+
await target.StartAsync().ConfigureAwait(false);
96+
this.Report();
97+
}
98+
catch (Exception ex)
99+
{
100+
// A bind refused because the network has not finished waking is the common one, and it
101+
// is why the Mobile package retries. Shown rather than swallowed: a sample that fails
102+
// silently teaches the failure mode this repo keeps trying to eliminate.
103+
this.Report(ex);
104+
}
105+
}
106+
107+
async Task StopAsync()
108+
{
109+
if (this.server is not { IsRunning: true } target)
110+
return;
111+
112+
try
113+
{
114+
await target.StopAsync().ConfigureAwait(false);
115+
}
116+
catch (Exception)
117+
{
118+
// Going to the background regardless.
119+
}
120+
121+
this.Report();
122+
}
123+
124+
void Report(Exception? error = null)
125+
{
126+
var running = this.server?.IsRunning ?? false;
127+
var addresses = LocalAddresses.Current().Select(a => $"http://{a}:8080").ToArray();
128+
129+
// UIKit is main-thread only and both transitions above complete off it.
130+
UIApplication.SharedApplication.InvokeOnMainThread(
131+
() => this.controller?.Update(running, addresses, error)
132+
);
133+
}
134+
135+
static string StatusPage()
136+
=> """
137+
<!doctype html>
138+
<meta charset="utf-8">
139+
<meta name="viewport" content="width=device-width,initial-scale=1">
140+
<title>Shiny.Net.HttpServer on tvOS</title>
141+
<style>
142+
body { font: 16px/1.6 system-ui, sans-serif; max-width: 34rem; margin: 12vh auto; padding: 0 1.5rem; }
143+
code { background: #f4f4f5; padding: .15em .4em; border-radius: .25rem; }
144+
</style>
145+
<h1>Served from an Apple TV</h1>
146+
<p>
147+
This page came from <code>Shiny.Net.HttpServer</code> running inside a tvOS app, on a
148+
platform where ASP.NET Core cannot go.
149+
</p>
150+
<p>The server stops when the app is backgrounded and starts again on resume.</p>
151+
<p><a href="/ping">/ping</a></p>
152+
""";
153+
}

‎samples/Sample.TvOS/Info.plist‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleName</key>
6+
<string>Shiny HTTP Server</string>
7+
<key>CFBundleIdentifier</key>
8+
<string>net.shinylib.httpserver.sample.tvos</string>
9+
<key>CFBundleVersion</key>
10+
<string>1.0</string>
11+
<key>CFBundleShortVersionString</key>
12+
<string>1.0</string>
13+
<key>LSRequiresIPhoneOS</key>
14+
<true/>
15+
<key>UIDeviceFamily</key>
16+
<array>
17+
<integer>3</integer>
18+
</array>
19+
<key>UILaunchStoryboardName</key>
20+
<string></string>
21+
22+
<!-- The entry this whole sample exists to demonstrate. tvOS 16+ gates the local network the same
23+
way iOS 14+ does, and it gates *serving* on it as much as connecting out. Without this key
24+
the listener binds, reports itself as running, and no other device on the network can reach
25+
it - with nothing in the log to say why. It is the failure LocalNetworkAccess.Check() was
26+
written to catch, and on a TV it is worse than on a phone, because there is no browser on
27+
the device to test with. -->
28+
<key>NSLocalNetworkUsageDescription</key>
29+
<string>Serves this app's status page to other devices on your network.</string>
30+
</dict>
31+
</plist>

‎samples/Sample.TvOS/Main.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using UIKit;
2+
3+
namespace Sample.TvOS;
4+
5+
static class Application
6+
{
7+
static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate));
8+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<!-- samples/Directory.Build.props sets a single net10.0 TargetFramework for the console
5+
samples. This one is an app bundle for a single Apple platform, so that is replaced
6+
rather than added to. -->
7+
<TargetFramework>net10.0-tvos</TargetFramework>
8+
<SupportedOSPlatformVersion>17.0</SupportedOSPlatformVersion>
9+
10+
<OutputType>Exe</OutputType>
11+
<RootNamespace>Sample.TvOS</RootNamespace>
12+
<Nullable>enable</Nullable>
13+
<ImplicitUsings>enable</ImplicitUsings>
14+
15+
<ApplicationId>net.shinylib.httpserver.sample.tvos</ApplicationId>
16+
<ApplicationTitle>Shiny HTTP Server</ApplicationTitle>
17+
18+
<!-- The console samples are the AOT proof and publish with PublishAot. A tvOS app does not:
19+
the platform is AOT already - Mono's full AOT, no JIT anywhere - and the switch belongs
20+
to ILCompiler, which does not build Apple app bundles. The guarantee this sample makes
21+
is the same one, arrived at by the platform rather than by a property. -->
22+
<PublishAot>false</PublishAot>
23+
</PropertyGroup>
24+
25+
<!-- Microsoft.Extensions.Logging.Console is deliberately absent, and the reason is worth
26+
recording because it fails late and loudly: it P/Invokes GetStdHandle and GetConsoleMode,
27+
and on tvOS that is not a runtime problem but a *native link* failure - "Undefined symbols
28+
for architecture arm64" out of clang, long after the C# has compiled clean. A TV app has no
29+
console to write to in any case. No logging provider is registered at all here: the server
30+
resolves ILoggerFactory optionally, so leaving it out is supported rather than a gap. -->
31+
<ItemGroup>
32+
<ProjectReference Include="../../src/Shiny.Net.HttpServer/Shiny.Net.HttpServer.csproj"/>
33+
</ItemGroup>
34+
35+
</Project>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using UIKit;
2+
3+
namespace Sample.TvOS;
4+
5+
/// <summary>
6+
/// The screen. A TV has no browser and no address bar, so the one thing this has to do well is say
7+
/// what to type on the device that does.
8+
/// </summary>
9+
public class ServerViewController : UIViewController
10+
{
11+
readonly UILabel status = Label(58, UIColor.White);
12+
readonly UILabel detail = Label(34, UIColor.LightGray);
13+
14+
public override void ViewDidLoad()
15+
{
16+
base.ViewDidLoad();
17+
18+
this.View!.BackgroundColor = UIColor.FromRGB(18, 18, 20);
19+
20+
var stack = new UIStackView([this.status, this.detail])
21+
{
22+
Axis = UILayoutConstraintAxis.Vertical,
23+
Alignment = UIStackViewAlignment.Center,
24+
Spacing = 28,
25+
TranslatesAutoresizingMaskIntoConstraints = false
26+
};
27+
28+
this.View.AddSubview(stack);
29+
30+
NSLayoutConstraint.ActivateConstraints([
31+
stack.CenterXAnchor.ConstraintEqualTo(this.View.CenterXAnchor),
32+
stack.CenterYAnchor.ConstraintEqualTo(this.View.CenterYAnchor),
33+
stack.WidthAnchor.ConstraintLessThanOrEqualTo(this.View.WidthAnchor, 0.8f)
34+
]);
35+
36+
this.Update(false, [], null);
37+
}
38+
39+
public void Update(bool running, IReadOnlyList<string> urls, Exception? error)
40+
{
41+
if (error is not null)
42+
{
43+
this.status.Text = "Could not start";
44+
this.status.TextColor = UIColor.FromRGB(255, 105, 97);
45+
this.detail.Text = error.Message;
46+
return;
47+
}
48+
49+
this.status.Text = running ? "Serving" : "Stopped";
50+
this.status.TextColor = running ? UIColor.FromRGB(120, 220, 140) : UIColor.LightGray;
51+
52+
this.detail.Text = running
53+
? urls.Count > 0
54+
? "Open on another device:\n" + String.Join("\n", urls)
55+
// Bound and listening, but the addresses came back empty. Worth saying plainly:
56+
// this is what a missing NSLocalNetworkUsageDescription tends to look like from
57+
// inside the app, which is to say almost exactly like everything working.
58+
: "Listening, but this device reports no LAN address.\nCheck NSLocalNetworkUsageDescription in Info.plist."
59+
: "The app is in the background. It starts again on resume.";
60+
}
61+
62+
static UILabel Label(int size, UIColor color)
63+
=> new()
64+
{
65+
TextAlignment = UITextAlignment.Center,
66+
Lines = 0,
67+
TextColor = color,
68+
Font = UIFont.SystemFontOfSize(size, UIFontWeight.Medium)!
69+
};
70+
}

0 commit comments

Comments
 (0)