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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1235,9 +1235,34 @@
|
|||
|
||||
<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}"
|
||||
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.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
|||
|
||||
public bool IsRunning { get; private set; }
|
||||
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>
|
||||
/// JSON serializer options shared across all responses. Camel-case property
|
||||
|
|
@ -81,24 +83,45 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </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();
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ public sealed class OscBridge : IAsyncDisposable
|
|||
|
||||
public bool IsRunning { get; private set; }
|
||||
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(
|
||||
IIsoController controller,
|
||||
|
|
@ -59,27 +61,35 @@ public sealed class OscBridge : IAsyncDisposable
|
|||
_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();
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
/// <summary>
|
||||
/// Record each newly-enabled ISO's normalized output to disk under
|
||||
|
|
@ -246,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ControlSurfaceLanReachable"/>:
|
||||
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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; }
|
||||
|
||||
/// <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>
|
||||
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
|
||||
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
|
||||
|
|
|
|||
Loading…
Reference in a new issue