diff --git a/docs/CONTROL-SURFACE.md b/docs/CONTROL-SURFACE.md
index b7377d6..0d8cc7d 100644
--- a/docs/CONTROL-SURFACE.md
+++ b/docs/CONTROL-SURFACE.md
@@ -9,18 +9,42 @@ node-RED flows, command-line scripts) can drive it without a UI binding.
1. Open TeamsISO → Settings → DISPLAY tab.
2. Tick "Control surface (Stream Deck / Companion)".
3. Default port is **9755**; change it via the port textbox if needed.
-4. The server binds to `127.0.0.1` only — it is NOT reachable from the LAN.
- If you need LAN access (e.g. a Stream Deck on a separate control PC),
- front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge.
+4. By default the server binds to `127.0.0.1` only — it is NOT reachable
+ from the LAN.
+5. To allow other machines on the same network to drive TeamsISO (the
+ "headless host PC + thin client" scenario), tick the nested
+ "LAN-reachable" checkbox underneath. The settings panel will display
+ the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button.
When enabled, the toast confirms `Control surface listening on
-http://127.0.0.1:9755/`.
+http://127.0.0.1:9755/` (or the all-interfaces equivalent in LAN mode).
+
+### One-time setup for LAN-reachable mode
+
+Windows requires elevated permission to bind a non-loopback HTTP listener.
+If you turn on LAN-reachable mode and don't see a connection from another
+machine, run this **once** in an Administrator PowerShell (replace `9755`
+if you've changed the port):
+
+```powershell
+netsh http add urlacl url=http://+:9755/ user=Everyone
+```
+
+Also confirm the Windows Firewall is letting inbound traffic to that port
+through — `New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
+in an elevated PowerShell, or add it through Windows Defender Firewall →
+Advanced Settings → Inbound Rules.
## Authentication
-None. The localhost-only bind is the security model. Any process on the
-operator's machine can hit these endpoints, which is the same threat model
-as a Stream Deck's USB connection.
+None — by design. In localhost-only mode, the loopback bind is the
+security model: any process on the operator's machine can hit these
+endpoints, the same threat model as a Stream Deck's USB connection.
+
+In LAN-reachable mode, the assumption is a closed/trusted network (a
+production-control LAN, a dedicated show subnet, a private vlan). Any
+machine that can route to the host on the listener port can drive
+TeamsISO. **Do not enable LAN-reachable mode on an untrusted network.**
## Response shape
@@ -222,7 +246,9 @@ Client→server messages are ignored for v1 — all commands go through REST.
Same command surface, different transport. Enable the OSC bridge in the
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
-`127.0.0.1` only.
+`127.0.0.1` by default; honors the same LAN-reachable toggle as the REST
+surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
+on the same network can talk to the host directly.
Address vocabulary:
@@ -266,11 +292,7 @@ on the appropriate endpoint above.
## Future work
-- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same
- command surface, different transport. Adapter sits in front of the REST
- handlers.
-- **Bidirectional state** via WebSocket — push `participants` updates so
- controllers can light a button when an ISO is live without polling.
-- **REST apply-preset** — duplicate the dialog's apply logic into
- `IIsoController.ApplyPreset(name)` so the `/presets/{name}/apply`
- endpoint becomes a real action.
+- **HTTPS / token auth** — for deployments that don't have a closed
+ network, layer TLS termination + a shared bearer token in front of the
+ HttpListener. Out of scope for v1; the LAN-reachable mode is a
+ trusted-network feature only.
diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml
index 8d0a2f3..40d006c 100644
--- a/src/TeamsISO.App/MainWindow.xaml
+++ b/src/TeamsISO.App/MainWindow.xaml
@@ -1235,9 +1235,34 @@
-
+ ToolTip="Start an HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) or a thin-client browser can drive TeamsISO."/>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/Services/ControlSurfaceServer.cs b/src/TeamsISO.App/Services/ControlSurfaceServer.cs
index ccb7e63..b6f94fe 100644
--- a/src/TeamsISO.App/Services/ControlSurfaceServer.cs
+++ b/src/TeamsISO.App/Services/ControlSurfaceServer.cs
@@ -59,6 +59,8 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort;
+ /// True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.
+ public bool BoundToLan { get; private set; }
///
/// JSON serializer options shared across all responses. Camel-case property
@@ -81,24 +83,45 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
}
///
- /// Start listening on the given port. Idempotent: if already running on a
- /// different port, stop + restart on the new one.
+ /// Start listening on the given port. Idempotent: if already running on the
+ /// same (port, bindToLan) combination, no-op; otherwise stop + restart.
///
- public void Start(int port)
+ /// TCP port to listen on.
+ ///
+ /// When true, binds to all interfaces (http://+:port/) so other
+ /// machines on the LAN can reach the control surface — typical for
+ /// "headless show machine + thin client controller" setups. When false
+ /// (default), binds to 127.0.0.1 only.
+ ///
+ /// LAN binding requires either running TeamsISO as Administrator OR a
+ /// one-time URL ACL reservation at the OS level:
+ /// netsh http add urlacl url=http://+:9755/ user=Everyone
+ /// If neither is in place the listener throws AccessDeniedException
+ /// which we catch and surface as a logger warning.
+ ///
+ public void Start(int port, bool bindToLan = false)
{
- if (IsRunning && Port == port) return;
+ if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop();
Port = port;
+ BoundToLan = bindToLan;
_listener = new HttpListener();
- _listener.Prefixes.Add($"http://127.0.0.1:{port}/");
+ var prefix = bindToLan
+ ? $"http://+:{port}/"
+ : $"http://127.0.0.1:{port}/";
+ _listener.Prefixes.Add(prefix);
try
{
_listener.Start();
}
catch (HttpListenerException ex)
{
- _logger?.LogWarning(ex, "Could not start control surface on port {Port}.", port);
+ _logger?.LogWarning(ex,
+ "Could not start control surface on {Prefix}. " +
+ "If binding to LAN, run as Administrator once OR run: " +
+ "netsh http add urlacl url=http://+:{Port}/ user=Everyone",
+ prefix, port);
_listener = null;
return;
}
@@ -122,7 +145,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
}
IsRunning = true;
- _logger?.LogInformation("Control surface listening on http://127.0.0.1:{Port}/ (REST + ws)", port);
+ _logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix);
}
public void Stop()
diff --git a/src/TeamsISO.App/Services/OscBridge.cs b/src/TeamsISO.App/Services/OscBridge.cs
index 818abb1..0b1c1e2 100644
--- a/src/TeamsISO.App/Services/OscBridge.cs
+++ b/src/TeamsISO.App/Services/OscBridge.cs
@@ -48,6 +48,8 @@ public sealed class OscBridge : IAsyncDisposable
public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort;
+ /// True when the listener is bound to all interfaces rather than just loopback.
+ public bool BoundToLan { get; private set; }
public OscBridge(
IIsoController controller,
@@ -59,27 +61,35 @@ public sealed class OscBridge : IAsyncDisposable
_logger = logger;
}
- public void Start(int port)
+ ///
+ /// Start the OSC listener on the given UDP port.
+ /// flag selects between loopback (default — only this machine) and any-
+ /// interface binding (LAN-reachable, for thin-client controllers).
+ /// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0
+ /// is just an unprivileged port reservation.
+ ///
+ public void Start(int port, bool bindToLan = false)
{
- if (IsRunning && Port == port) return;
+ if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop();
Port = port;
+ BoundToLan = bindToLan;
+ var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback;
try
{
- // Bind to loopback only — same threat model as the REST surface.
- _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
+ _udp = new UdpClient(new IPEndPoint(bindAddr, port));
}
catch (SocketException ex)
{
- _logger?.LogWarning(ex, "Could not bind OSC bridge to udp://127.0.0.1:{Port}.", port);
+ _logger?.LogWarning(ex, "Could not bind OSC bridge to udp://{Addr}:{Port}.", bindAddr, port);
_udp = null;
return;
}
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
IsRunning = true;
- _logger?.LogInformation("OSC bridge listening on udp://127.0.0.1:{Port}/", port);
+ _logger?.LogInformation("OSC bridge listening on udp://{Addr}:{Port}/", bindAddr, port);
}
public void Stop()
diff --git a/src/TeamsISO.App/Services/UIPreferences.cs b/src/TeamsISO.App/Services/UIPreferences.cs
index 027d007..0861919 100644
--- a/src/TeamsISO.App/Services/UIPreferences.cs
+++ b/src/TeamsISO.App/Services/UIPreferences.cs
@@ -38,7 +38,8 @@ public static class UIPreferences
bool HideLocalSelf = true,
bool AutoDisableOnDeparture = false,
SortMode ParticipantSort = SortMode.JoinOrder,
- bool MinimizeToTray = false);
+ bool MinimizeToTray = false,
+ bool ControlSurfaceLanReachable = false);
public static Prefs Load()
{
diff --git a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
index 6ef4736..f1afabc 100644
--- a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
@@ -33,6 +33,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray;
+ private bool _controlSurfaceLanReachable;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
@@ -57,6 +58,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
_participantSort = uiPrefs.ParticipantSort;
_minimizeToTray = uiPrefs.MinimizeToTray;
+ _controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
@@ -77,6 +79,18 @@ public sealed class GlobalSettingsViewModel : ObservableObject
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
+ CopyControlSurfaceUrlCommand = new RelayCommand(() =>
+ {
+ try
+ {
+ System.Windows.Clipboard.SetText(ControlSurfaceUrl);
+ _toast?.Show($"Copied: {ControlSurfaceUrl}");
+ }
+ catch
+ {
+ // Clipboard occasionally errors when something else has it locked.
+ }
+ });
}
private void ResetOutputDefaults()
@@ -207,7 +221,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort,
- MinimizeToTray: _minimizeToTray));
+ MinimizeToTray: _minimizeToTray,
+ ControlSurfaceLanReachable: _controlSurfaceLanReachable));
///
/// Record each newly-enabled ISO's normalized output to disk under
@@ -246,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
///
- /// REST control surface (localhost:port) — Stream Deck / Companion / OSC bridges
- /// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit
- /// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer.
+ /// REST control surface — Stream Deck / Companion / thin-client controllers.
+ /// Off by default. Bind address depends on :
+ /// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
///
public bool ControlSurfaceEnabled
{
@@ -258,10 +273,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _controlSurfaceEnabled, value)) return;
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
- if (value) srv.Start(_controlSurfacePort);
+ if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
else srv.Stop();
_toast?.Show(value
- ? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/"
+ ? $"Control surface listening on {ControlSurfaceUrl}"
: "Control surface stopped");
}
}
@@ -276,13 +291,88 @@ public sealed class GlobalSettingsViewModel : ObservableObject
set
{
if (!SetField(ref _controlSurfacePort, value)) return;
+ OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
- srv?.Start(value); // Start is idempotent + handles port change
- _toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/");
+ srv?.Start(value, _controlSurfaceLanReachable);
+ _toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
}
}
+ ///
+ /// LAN-reachable mode. When false (default), control surface binds to
+ /// 127.0.0.1 — only this machine. When true, binds to all interfaces so
+ /// a thin-client controller on a phone or another laptop can drive
+ /// TeamsISO. The OSC bridge follows suit if it's running.
+ ///
+ /// Important: HttpListener requires either Administrator privilege OR a
+ /// one-time URL ACL reservation for non-loopback prefixes:
+ /// netsh http add urlacl url=http://+:9755/ user=Everyone
+ /// (run from an elevated PowerShell). Without that the listener throws
+ /// AccessDeniedException on Start; the failure surfaces as a logger
+ /// warning with the exact netsh command.
+ ///
+ public bool ControlSurfaceLanReachable
+ {
+ get => _controlSurfaceLanReachable;
+ set
+ {
+ if (!SetField(ref _controlSurfaceLanReachable, value)) return;
+ PersistUiPrefs();
+ OnPropertyChanged(nameof(ControlSurfaceUrl));
+ if (!_controlSurfaceEnabled) return;
+ var srv = (Application.Current as App)?.ControlSurface;
+ srv?.Start(_controlSurfacePort, value);
+ var osc = (Application.Current as App)?.OscBridge;
+ if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
+ _toast?.Show(value
+ ? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
+ : "Control surface now loopback-only");
+ }
+ }
+
+ ///
+ /// Friendly URL of the running surface, for the settings panel + status
+ /// bar tooltip. Resolves to the first non-loopback IPv4 address when
+ /// LAN-reachable; loopback otherwise. Computed on demand because the
+ /// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
+ ///
+ public string ControlSurfaceUrl
+ {
+ get
+ {
+ var host = _controlSurfaceLanReachable
+ ? GetLanIPv4() ?? "127.0.0.1"
+ : "127.0.0.1";
+ return $"http://{host}:{_controlSurfacePort}/ui";
+ }
+ }
+
+ ///
+ /// Best-effort first IPv4 address that isn't loopback. Returns null if
+ /// no LAN interface is up. The first hit is good enough for the URL —
+ /// operators with multi-NIC setups can manually substitute.
+ ///
+ private static string? GetLanIPv4()
+ {
+ try
+ {
+ foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
+ if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
+ foreach (var ua in ni.GetIPProperties().UnicastAddresses)
+ {
+ if (ua.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
+ && !System.Net.IPAddress.IsLoopback(ua.Address))
+ return ua.Address.ToString();
+ }
+ }
+ }
+ catch { /* best-effort */ }
+ return null;
+ }
+
///
/// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
@@ -296,10 +386,11 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _oscBridgeEnabled, value)) return;
var bridge = (Application.Current as App)?.OscBridge;
if (bridge is null) return;
- if (value) bridge.Start(_oscBridgePort);
+ if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
else bridge.Stop();
+ var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show(value
- ? $"OSC bridge listening on udp://127.0.0.1:{_oscBridgePort}/"
+ ? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
: "OSC bridge stopped");
}
}
@@ -313,8 +404,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _oscBridgePort, value)) return;
if (!_oscBridgeEnabled) return;
var bridge = (Application.Current as App)?.OscBridge;
- bridge?.Start(value);
- _toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{value}/");
+ bridge?.Start(value, _controlSurfaceLanReachable);
+ var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
+ _toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
}
}
@@ -381,6 +473,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
public AsyncRelayCommand ApplyCommand { get; }
+ ///
+ /// Copy the current control-surface URL to the clipboard. Operators on a
+ /// thin-client setup tap this, then paste into a phone browser. Bound to
+ /// a small button next to the LAN-reachable toggle.
+ ///
+ public RelayCommand CopyControlSurfaceUrlCommand { get; }
+
///
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —