Add LAN-reachable mode to control surface and OSC bridge
Some checks failed
CI / build-and-test (push) Failing after 27s
Some checks failed
CI / build-and-test (push) Failing after 27s
When the new ControlSurfaceLanReachable preference is on, both the REST/WebSocket control surface and the OSC bridge bind to all interfaces (http://+:port/ via HttpListener wildcard, IPAddress.Any for OSC) instead of loopback. The settings VM persists the toggle, restarts both surfaces when flipped, and surfaces a ControlSurfaceUrl computed from the first non-loopback IPv4 + a Copy button so operators can paste the URL onto a control PC. Use case: a headless host PC runs Teams + TeamsISO; a thin client on the same LAN drives it via /ui or a Stream Deck. Closed-network deployment, no auth — documented as a trusted-LAN-only mode in docs/CONTROL-SURFACE.md, including the one-time 'netsh http add urlacl url=http://+:9755/ user=Everyone' requirement and the firewall rule.
This commit is contained in:
parent
63bd93d0c2
commit
6d9407a61f
6 changed files with 224 additions and 44 deletions
|
|
@ -9,18 +9,42 @@ node-RED flows, command-line scripts) can drive it without a UI binding.
|
||||||
1. Open TeamsISO → Settings → DISPLAY tab.
|
1. Open TeamsISO → Settings → DISPLAY tab.
|
||||||
2. Tick "Control surface (Stream Deck / Companion)".
|
2. Tick "Control surface (Stream Deck / Companion)".
|
||||||
3. Default port is **9755**; change it via the port textbox if needed.
|
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.
|
4. By default the server binds to `127.0.0.1` only — it is NOT reachable
|
||||||
If you need LAN access (e.g. a Stream Deck on a separate control PC),
|
from the LAN.
|
||||||
front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge.
|
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
|
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
|
## Authentication
|
||||||
|
|
||||||
None. The localhost-only bind is the security model. Any process on the
|
None — by design. In localhost-only mode, the loopback bind is the
|
||||||
operator's machine can hit these endpoints, which is the same threat model
|
security model: any process on the operator's machine can hit these
|
||||||
as a Stream Deck's USB connection.
|
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
|
## 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
|
Same command surface, different transport. Enable the OSC bridge in the
|
||||||
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
|
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:
|
Address vocabulary:
|
||||||
|
|
||||||
|
|
@ -266,11 +292,7 @@ on the appropriate endpoint above.
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|
||||||
- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same
|
- **HTTPS / token auth** — for deployments that don't have a closed
|
||||||
command surface, different transport. Adapter sits in front of the REST
|
network, layer TLS termination + a shared bearer token in front of the
|
||||||
handlers.
|
HttpListener. Out of scope for v1; the LAN-reachable mode is a
|
||||||
- **Bidirectional state** via WebSocket — push `participants` updates so
|
trusted-network feature only.
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1235,9 +1235,34 @@
|
||||||
|
|
||||||
<Separator Margin="0,16,0,8"/>
|
<Separator Margin="0,16,0,8"/>
|
||||||
|
|
||||||
<CheckBox Content="Control surface (Stream Deck / Companion)"
|
<CheckBox Content="Control surface (Stream Deck / Companion / web)"
|
||||||
IsChecked="{Binding Settings.ControlSurfaceEnabled}"
|
IsChecked="{Binding Settings.ControlSurfaceEnabled}"
|
||||||
ToolTip="Start a localhost-only HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) can drive TeamsISO. Bound to 127.0.0.1; not reachable from LAN."/>
|
ToolTip="Start an HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) or a thin-client browser can drive TeamsISO."/>
|
||||||
|
|
||||||
|
<CheckBox Content="LAN-reachable (allow other machines on your network)"
|
||||||
|
IsChecked="{Binding Settings.ControlSurfaceLanReachable}"
|
||||||
|
Margin="20,4,0,0"
|
||||||
|
ToolTip="When checked, the control surface binds to all interfaces (0.0.0.0) instead of 127.0.0.1, so a phone or laptop on your LAN can drive TeamsISO. First time you turn this on, run this in an Administrator PowerShell once: netsh http add urlacl url=http://+:9755/ user=Everyone"/>
|
||||||
|
|
||||||
|
<Grid Margin="20,4,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Settings.ControlSurfaceUrl}"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Copy"
|
||||||
|
Command="{Binding Settings.CopyControlSurfaceUrlCommand}"
|
||||||
|
Padding="10,2"
|
||||||
|
FontSize="11"
|
||||||
|
ToolTip="Copy this URL to the clipboard so you can paste it into a phone browser or controller."/>
|
||||||
|
</Grid>
|
||||||
<Grid Margin="0,8,0,0">
|
<Grid Margin="0,8,0,0">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||||
|
|
||||||
public bool IsRunning { get; private set; }
|
public bool IsRunning { get; private set; }
|
||||||
public int Port { get; private set; } = DefaultPort;
|
public int Port { get; private set; } = DefaultPort;
|
||||||
|
/// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
|
||||||
|
public bool BoundToLan { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// JSON serializer options shared across all responses. Camel-case property
|
/// JSON serializer options shared across all responses. Camel-case property
|
||||||
|
|
@ -81,24 +83,45 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Start listening on the given port. Idempotent: if already running on a
|
/// Start listening on the given port. Idempotent: if already running on the
|
||||||
/// different port, stop + restart on the new one.
|
/// same (port, bindToLan) combination, no-op; otherwise stop + restart.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Start(int port)
|
/// <param name="port">TCP port to listen on.</param>
|
||||||
|
/// <param name="bindToLan">
|
||||||
|
/// When true, binds to all interfaces (<c>http://+:port/</c>) 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 <c>127.0.0.1</c> only.
|
||||||
|
///
|
||||||
|
/// LAN binding requires either running TeamsISO as Administrator OR a
|
||||||
|
/// one-time URL ACL reservation at the OS level:
|
||||||
|
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
||||||
|
/// If neither is in place the listener throws AccessDeniedException
|
||||||
|
/// which we catch and surface as a logger warning.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(int port, bool bindToLan = false)
|
||||||
{
|
{
|
||||||
if (IsRunning && Port == port) return;
|
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
|
||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
Port = port;
|
Port = port;
|
||||||
|
BoundToLan = bindToLan;
|
||||||
_listener = new HttpListener();
|
_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
|
try
|
||||||
{
|
{
|
||||||
_listener.Start();
|
_listener.Start();
|
||||||
}
|
}
|
||||||
catch (HttpListenerException ex)
|
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;
|
_listener = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +145,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
IsRunning = true;
|
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()
|
public void Stop()
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ public sealed class OscBridge : IAsyncDisposable
|
||||||
|
|
||||||
public bool IsRunning { get; private set; }
|
public bool IsRunning { get; private set; }
|
||||||
public int Port { get; private set; } = DefaultPort;
|
public int Port { get; private set; } = DefaultPort;
|
||||||
|
/// <summary>True when the listener is bound to all interfaces rather than just loopback.</summary>
|
||||||
|
public bool BoundToLan { get; private set; }
|
||||||
|
|
||||||
public OscBridge(
|
public OscBridge(
|
||||||
IIsoController controller,
|
IIsoController controller,
|
||||||
|
|
@ -59,27 +61,35 @@ public sealed class OscBridge : IAsyncDisposable
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start(int port)
|
/// <summary>
|
||||||
|
/// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(int port, bool bindToLan = false)
|
||||||
{
|
{
|
||||||
if (IsRunning && Port == port) return;
|
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
|
||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
Port = port;
|
Port = port;
|
||||||
|
BoundToLan = bindToLan;
|
||||||
|
var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Bind to loopback only — same threat model as the REST surface.
|
_udp = new UdpClient(new IPEndPoint(bindAddr, port));
|
||||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
|
|
||||||
}
|
}
|
||||||
catch (SocketException ex)
|
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;
|
_udp = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
|
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
|
||||||
IsRunning = true;
|
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()
|
public void Stop()
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ public static class UIPreferences
|
||||||
bool HideLocalSelf = true,
|
bool HideLocalSelf = true,
|
||||||
bool AutoDisableOnDeparture = false,
|
bool AutoDisableOnDeparture = false,
|
||||||
SortMode ParticipantSort = SortMode.JoinOrder,
|
SortMode ParticipantSort = SortMode.JoinOrder,
|
||||||
bool MinimizeToTray = false);
|
bool MinimizeToTray = false,
|
||||||
|
bool ControlSurfaceLanReachable = false);
|
||||||
|
|
||||||
public static Prefs Load()
|
public static Prefs Load()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
|
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
|
||||||
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
|
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
|
||||||
private bool _minimizeToTray;
|
private bool _minimizeToTray;
|
||||||
|
private bool _controlSurfaceLanReachable;
|
||||||
|
|
||||||
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
||||||
{
|
{
|
||||||
|
|
@ -57,6 +58,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
|
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
|
||||||
_participantSort = uiPrefs.ParticipantSort;
|
_participantSort = uiPrefs.ParticipantSort;
|
||||||
_minimizeToTray = uiPrefs.MinimizeToTray;
|
_minimizeToTray = uiPrefs.MinimizeToTray;
|
||||||
|
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
|
||||||
|
|
||||||
// Bring the auto-apply flag in from the presets store so the checkbox
|
// Bring the auto-apply flag in from the presets store so the checkbox
|
||||||
// reflects the user's prior choice when the settings panel opens.
|
// reflects the user's prior choice when the settings panel opens.
|
||||||
|
|
@ -77,6 +79,18 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
||||||
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
|
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
|
||||||
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
|
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()
|
private void ResetOutputDefaults()
|
||||||
|
|
@ -207,7 +221,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
HideLocalSelf: _hideLocalSelf,
|
HideLocalSelf: _hideLocalSelf,
|
||||||
AutoDisableOnDeparture: _autoDisableOnDeparture,
|
AutoDisableOnDeparture: _autoDisableOnDeparture,
|
||||||
ParticipantSort: _participantSort,
|
ParticipantSort: _participantSort,
|
||||||
MinimizeToTray: _minimizeToTray));
|
MinimizeToTray: _minimizeToTray,
|
||||||
|
ControlSurfaceLanReachable: _controlSurfaceLanReachable));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Record each newly-enabled ISO's normalized output to disk under
|
/// Record each newly-enabled ISO's normalized output to disk under
|
||||||
|
|
@ -246,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// REST control surface (localhost:port) — Stream Deck / Companion / OSC bridges
|
/// REST control surface — Stream Deck / Companion / thin-client controllers.
|
||||||
/// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit
|
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
|
||||||
/// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer.
|
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ControlSurfaceEnabled
|
public bool ControlSurfaceEnabled
|
||||||
{
|
{
|
||||||
|
|
@ -258,10 +273,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
if (!SetField(ref _controlSurfaceEnabled, value)) return;
|
if (!SetField(ref _controlSurfaceEnabled, value)) return;
|
||||||
var srv = (Application.Current as App)?.ControlSurface;
|
var srv = (Application.Current as App)?.ControlSurface;
|
||||||
if (srv is null) return;
|
if (srv is null) return;
|
||||||
if (value) srv.Start(_controlSurfacePort);
|
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
|
||||||
else srv.Stop();
|
else srv.Stop();
|
||||||
_toast?.Show(value
|
_toast?.Show(value
|
||||||
? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/"
|
? $"Control surface listening on {ControlSurfaceUrl}"
|
||||||
: "Control surface stopped");
|
: "Control surface stopped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,13 +291,88 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (!SetField(ref _controlSurfacePort, value)) return;
|
if (!SetField(ref _controlSurfacePort, value)) return;
|
||||||
|
OnPropertyChanged(nameof(ControlSurfaceUrl));
|
||||||
if (!_controlSurfaceEnabled) return;
|
if (!_controlSurfaceEnabled) return;
|
||||||
var srv = (Application.Current as App)?.ControlSurface;
|
var srv = (Application.Current as App)?.ControlSurface;
|
||||||
srv?.Start(value); // Start is idempotent + handles port change
|
srv?.Start(value, _controlSurfaceLanReachable);
|
||||||
_toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/");
|
_toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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:
|
||||||
|
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
||||||
|
/// (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.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public string ControlSurfaceUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var host = _controlSurfaceLanReachable
|
||||||
|
? GetLanIPv4() ?? "127.0.0.1"
|
||||||
|
: "127.0.0.1";
|
||||||
|
return $"http://{host}:{_controlSurfacePort}/ui";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OSC bridge over UDP — same command surface as the REST endpoints,
|
/// OSC bridge over UDP — same command surface as the REST endpoints,
|
||||||
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
|
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
|
||||||
|
|
@ -296,10 +386,11 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
if (!SetField(ref _oscBridgeEnabled, value)) return;
|
if (!SetField(ref _oscBridgeEnabled, value)) return;
|
||||||
var bridge = (Application.Current as App)?.OscBridge;
|
var bridge = (Application.Current as App)?.OscBridge;
|
||||||
if (bridge is null) return;
|
if (bridge is null) return;
|
||||||
if (value) bridge.Start(_oscBridgePort);
|
if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
|
||||||
else bridge.Stop();
|
else bridge.Stop();
|
||||||
|
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
|
||||||
_toast?.Show(value
|
_toast?.Show(value
|
||||||
? $"OSC bridge listening on udp://127.0.0.1:{_oscBridgePort}/"
|
? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
|
||||||
: "OSC bridge stopped");
|
: "OSC bridge stopped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -313,8 +404,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
if (!SetField(ref _oscBridgePort, value)) return;
|
if (!SetField(ref _oscBridgePort, value)) return;
|
||||||
if (!_oscBridgeEnabled) return;
|
if (!_oscBridgeEnabled) return;
|
||||||
var bridge = (Application.Current as App)?.OscBridge;
|
var bridge = (Application.Current as App)?.OscBridge;
|
||||||
bridge?.Start(value);
|
bridge?.Start(value, _controlSurfaceLanReachable);
|
||||||
_toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{value}/");
|
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; }
|
public AsyncRelayCommand ApplyCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand CopyControlSurfaceUrlCommand { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
|
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
|
||||||
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
|
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue