diff --git a/.gitignore b/.gitignore index 93d822baf..6eb7e35bd 100644 --- a/.gitignore +++ b/.gitignore @@ -297,3 +297,40 @@ Server.Installer/Properties/launchSettings.json !/.vscode/tasks.json /Server/appsettings.Development.json /Server/AppData +# build/IDE +bin/ +obj/ +.vs/ +*.user +*.suo + +# artifacts +*.zip +*.7z +*.tar +*.tgz + +# system +Thumbs.db +bin/ +obj/ +.vs/ +*.user +*.suo +*.zip +*.7z +*.tgz +Thumbs.db +# .NET +bin/ +obj/ +.vs/ + +# Rider/other IDEs +.idea/ + +# Publish & local artifacts (don’t commit compiled builds) +publish-linux/ +artifacts/ +Server/wwwroot/Downloads/ +AppData/ diff --git a/Desktop.Shared/Enums/ButtonAction.cs b/Desktop.Shared/Enums/ButtonAction.cs index 8a3df55da..1b283fa06 100644 --- a/Desktop.Shared/Enums/ButtonAction.cs +++ b/Desktop.Shared/Enums/ButtonAction.cs @@ -3,5 +3,7 @@ public enum ButtonAction { Down, - Up + Up, + PrivacyOn, + PrivacyOff } diff --git a/Desktop.Shared/Messages/ButtonActionMessage.cs b/Desktop.Shared/Messages/ButtonActionMessage.cs new file mode 100644 index 000000000..c805a2eab --- /dev/null +++ b/Desktop.Shared/Messages/ButtonActionMessage.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Remotely.Desktop.Shared.Messages; + +using Remotely.Desktop.Shared.Enums; + +public sealed class ButtonActionMessage +{ + public ButtonAction Action { get; set; } +} diff --git a/Desktop.Win/Desktop.Win.csproj b/Desktop.Win/Desktop.Win.csproj index 165307d11..0b9503ff1 100644 --- a/Desktop.Win/Desktop.Win.csproj +++ b/Desktop.Win/Desktop.Win.csproj @@ -15,6 +15,8 @@ True enable + true + true diff --git a/Desktop.Win/PrivacyOverlay.cs b/Desktop.Win/PrivacyOverlay.cs new file mode 100644 index 000000000..155fe8262 --- /dev/null +++ b/Desktop.Win/PrivacyOverlay.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace Remotely.Desktop.Win; + +/// +/// Fullscreen black overlay per monitor that is excluded from screen capture +/// (local user sees black) but is click-through and non-activating so remote +/// input still reaches underlying windows. +/// +internal static class PrivacyOverlay +{ + private static readonly List
_overlays = new(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetWindowDisplayAffinity(IntPtr hWnd, uint dwAffinity); + + private const uint WDA_NONE = 0; + private const uint WDA_EXCLUDEFROMCAPTURE = 0x11; // Windows 10 2004+ (19041) + + // ---- Custom overlay form (click-through, non-activating, hidden from Alt+Tab) ---- + private sealed class OverlayForm : Form + { + // WS_EX_TOOLWINDOW (0x00000080) - hide from Alt+Tab + // WS_EX_LAYERED (0x00080000) - layered window + // WS_EX_TRANSPARENT(0x00000020) - mouse clicks pass through + // WS_EX_NOACTIVATE (0x08000000) - never takes focus + protected override CreateParams CreateParams + { + get + { + var cp = base.CreateParams; + cp.ExStyle |= 0x00000080 | 0x00080000 | 0x00000020 | 0x08000000; + return cp; + } + } + + protected override bool ShowWithoutActivation => true; + } + + public static bool IsActive => _overlays.Count > 0; + + public static void Enable() + { + if (IsActive) return; + + foreach (var screen in Screen.AllScreens) + { + var f = new OverlayForm + { + FormBorderStyle = FormBorderStyle.None, + StartPosition = FormStartPosition.Manual, + Bounds = screen.Bounds, + BackColor = Color.Black, + TopMost = true, + ShowInTaskbar = false, + Opacity = 1 + }; + + f.Load += (_, __) => + { + // Exclude from capture so only the local user sees black. + _ = SetWindowDisplayAffinity(f.Handle, WDA_EXCLUDEFROMCAPTURE); + }; + + // Best-effort: restore affinity on close + f.FormClosed += (_, __) => + { + try { _ = SetWindowDisplayAffinity(f.Handle, WDA_NONE); } catch { } + }; + + f.Show(); // non-activating + _overlays.Add(f); + } + } + + public static void Disable() + { + foreach (var f in _overlays) + { + try + { + _ = SetWindowDisplayAffinity(f.Handle, WDA_NONE); + if (!f.IsDisposed) f.Close(); + f.Dispose(); + } + catch { /* ignore */ } + } + _overlays.Clear(); + } + + public static void Toggle() + { + if (IsActive) Disable(); + else Enable(); + } +} diff --git a/Desktop.Win/Services/AppStartup.cs b/Desktop.Win/Services/AppStartup.cs index bdd713a6f..cef0f2aca 100644 --- a/Desktop.Win/Services/AppStartup.cs +++ b/Desktop.Win/Services/AppStartup.cs @@ -4,6 +4,13 @@ using Remotely.Desktop.Shared.Services; using Remotely.Desktop.UI.Services; using Remotely.Shared.Models; +using System.Drawing; +using System.IO; +using System.Windows.Forms; +using Remotely.Desktop.Win; // < add this if not already present + + + namespace Remotely.Desktop.Win.Services; @@ -21,6 +28,9 @@ internal class AppStartup : IAppStartup private readonly IShutdownService _shutdownService; private readonly IBrandingProvider _brandingProvider; private readonly ILogger _logger; + private NotifyIcon? _tray; + private ToolStripMenuItem? _privacyItem; + public AppStartup( IAppState appState, @@ -54,7 +64,64 @@ public async Task Run() { await _brandingProvider.Initialize(); + _messageLoop.StartMessageLoop(); + // ===== Tray icon + Privacy toggle ===== + if (_tray is null) + { + _tray = new NotifyIcon(); + + // Try to load an icon from Assets; fallback to default + var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "favicon.ico"); + _tray.Icon = File.Exists(iconPath) ? new Icon(iconPath) : SystemIcons.Application; + _tray.Visible = true; + _tray.Text = "Remotely Agent"; + + var menu = new ContextMenuStrip(); + + _privacyItem = new ToolStripMenuItem("Privacy Mode (Black Screen)") + { + CheckOnClick = true + }; + _privacyItem.CheckedChanged += (s, e) => + { + if (_privacyItem.Checked) + { + PrivacyOverlay.Enable(); + } + else + { + PrivacyOverlay.Disable(); + } + }; + + var exitItem = new ToolStripMenuItem("Exit Agent"); + exitItem.Click += (s, e) => + { + try { PrivacyOverlay.Disable(); } catch { } + if (_tray is not null) { _tray.Visible = false; } + Application.Exit(); + }; + + menu.Items.Add(_privacyItem); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add(exitItem); + + _tray.ContextMenuStrip = menu; + } + + // Ensure cleanup when the app is exiting (service stop / app exit) + _uiDispatcher.ApplicationExitingToken.Register(() => + { + try { PrivacyOverlay.Disable(); } catch { } + if (_tray is not null) + { + _tray.Visible = false; + _tray.Dispose(); + _tray = null; + } + }); + if (_appState.Mode is AppMode.Unattended or AppMode.Attended) { diff --git a/Desktop.Win/Services/MessageLoop.cs b/Desktop.Win/Services/MessageLoop.cs index e30b8a467..456388b1e 100644 --- a/Desktop.Win/Services/MessageLoop.cs +++ b/Desktop.Win/Services/MessageLoop.cs @@ -1,11 +1,17 @@ -using Remotely.Desktop.Shared.Messages; -using Remotely.Desktop.UI.Services; -using Remotely.Shared.Enums; -using Bitbound.SimpleMessenger; -using Microsoft.Win32; +using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using Bitbound.SimpleMessenger; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; +using Remotely.Desktop.Shared.Enums; // ButtonAction +using Remotely.Desktop.Shared.Messages; // ButtonActionMessage +using Remotely.Desktop.UI.Services; +using Remotely.Desktop.Win; // PrivacyOverlay +using Remotely.Shared.Enums; // SessionEndReasonsEx, SessionSwitchReasonEx namespace Remotely.Desktop.Win.Services; @@ -20,7 +26,9 @@ public class MessageLoop : IMessageLoop private readonly CancellationToken _exitToken; private readonly ILogger _logger; private readonly IMessenger _messenger; + private Thread? _messageLoopThread; + private IDisposable? _buttonActionReg; // unregister on exit public MessageLoop( IMessenger messenger, @@ -30,15 +38,30 @@ public MessageLoop( _messenger = messenger; _logger = logger; _exitToken = uiDispatcher.ApplicationExitingToken; + + // ⚠️ Your IMessenger.Register requires (object recipient, RegistrationCallback) + // and the callback returns Task. So we pass "this" and a Task-returning method. + _buttonActionReg = _messenger.Register(this, OnButtonAction); + + // Cleanup on shutdown + _exitToken.Register(() => + { + try { _buttonActionReg?.Dispose(); } catch { } + try { PrivacyOverlay.Disable(); } catch { } + }); } + // Registration callback signature: Task(object recipient, TMessage msg) + private Task OnButtonAction(object _, ButtonActionMessage msg) + { + HandleButtonAction(msg); + return Task.CompletedTask; + } public void StartMessageLoop() { if (_messageLoopThread is not null) - { throw new InvalidOperationException("Message loop already started."); - } _messageLoopThread = new Thread(() => { @@ -51,9 +74,7 @@ public void StartMessageLoop() try { while (GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) - { DispatchMessage(ref msg); - } } catch (Exception ex) { @@ -65,37 +86,53 @@ public void StartMessageLoop() SystemEvents.SessionEnding -= SystemEvents_SessionEnding; SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged; }); + _messageLoopThread.SetApartmentState(ApartmentState.STA); _messageLoopThread.Start(); } + // ---- Win32 message pump ---- [DllImport("user32.dll")] private static extern bool DispatchMessage([In] ref MSG lpmsg); [DllImport("user32.dll")] private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); - - private void SystemEvents_DisplaySettingsChanged(object? sender, EventArgs e) - { + // ---- System event relays ---- + private void SystemEvents_DisplaySettingsChanged(object? sender, EventArgs e) => _messenger.Send(new DisplaySettingsChangedMessage()); - } private void SystemEvents_SessionEnding(object sender, SessionEndingEventArgs e) { - _logger.LogInformation("Session ending. Reason: {reason}", e.Reason); - + _logger.LogInformation("Session ending. Reason: {reason}", e.Reason); var reason = (SessionEndReasonsEx)e.Reason; _messenger.Send(new WindowsSessionEndingMessage(reason)); } private void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e) { - _logger.LogInformation("Session changing. Reason: {reason}", e.Reason); - + _logger.LogInformation("Session changing. Reason: {reason}", e.Reason); var reason = (SessionSwitchReasonEx)(int)e.Reason; _messenger.Send(new WindowsSessionSwitchedMessage(reason, Process.GetCurrentProcess().SessionId)); } + + // ---- Handle viewer toolbar actions (Privacy) ---- + private void HandleButtonAction(ButtonActionMessage msg) + { + switch (msg.Action) + { + case ButtonAction.PrivacyOn: + PrivacyOverlay.Enable(); + break; + case ButtonAction.PrivacyOff: + PrivacyOverlay.Disable(); + break; + default: + break; + } + } + + // ---- Win32 structs ---- [StructLayout(LayoutKind.Sequential)] private struct MSG { @@ -113,10 +150,6 @@ private struct POINT public int X; public int Y; - public POINT(int x, int y) - { - X = x; - Y = y; - } + public POINT(int x, int y) { X = x; Y = y; } } } diff --git a/Server/Hubs/DesktopHub.cs b/Server/Hubs/DesktopHub.cs index 61e6a19be..72363722c 100644 --- a/Server/Hubs/DesktopHub.cs +++ b/Server/Hubs/DesktopHub.cs @@ -4,6 +4,8 @@ using Remotely.Shared.Enums; using Remotely.Shared.Interfaces; using Microsoft.AspNetCore.SignalR; +using Remotely.Desktop.Shared.Messages; + namespace Remotely.Server.Hubs; diff --git a/Server/Hubs/ViewerHub.cs b/Server/Hubs/ViewerHub.cs index 3aba18400..f8840127b 100644 --- a/Server/Hubs/ViewerHub.cs +++ b/Server/Hubs/ViewerHub.cs @@ -1,43 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Remotely.Desktop.Shared.Enums; +using Remotely.Desktop.Shared.Messages; using Remotely.Server.Enums; using Remotely.Server.Filters; using Remotely.Server.Models; using Remotely.Server.Services; using Remotely.Shared.Interfaces; using Remotely.Shared.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; namespace Remotely.Server.Hubs; [ServiceFilter(typeof(ViewerAuthorizationFilter))] public class ViewerHub : Hub { - private readonly IHubContext _desktopHub; + private readonly IHubContext _desktopHub; // typed (used elsewhere) + private readonly IHubContext _desktopHubUntyped; // untyped (for SendAsync) private readonly IHubContext _agentHub; private readonly IDataService _dataService; private readonly ISessionRecordingSink _sessionRecordingSink; private readonly IRemoteControlSessionCache _desktopSessionCache; private readonly ILogger _logger; private readonly IDesktopStreamCache _streamCache; + private readonly IHubContext _desktopHubRaw; + + + + public ViewerHub( - IRemoteControlSessionCache desktopSessionCache, - IDesktopStreamCache streamCache, - IHubContext agentHub, - IHubContext desktopHub, - ISessionRecordingSink sessionRecordingSink, - IDataService dataService, - ILogger logger) + IRemoteControlSessionCache desktopSessionCache, + IDesktopStreamCache streamCache, + IHubContext agentHub, + IHubContext desktopHub, + IHubContext desktopHubRaw, // < ADD THIS + ISessionRecordingSink sessionRecordingSink, + IDataService dataService, + ILogger logger) { _desktopSessionCache = desktopSessionCache; _streamCache = streamCache; _desktopHub = desktopHub; + _desktopHubRaw = desktopHubRaw; // < AND ASSIGN _agentHub = agentHub; _dataService = dataService; _sessionRecordingSink = sessionRecordingSink; _logger = logger; } + // Called from viewer to toggle privacy on the desktop side. + public Task TogglePrivacy(bool enable) + { + if (string.IsNullOrWhiteSpace(SessionInfo.DesktopConnectionId)) + return Task.CompletedTask; + + var msg = new Remotely.Desktop.Shared.Messages.ButtonActionMessage + { + Action = enable + ? Remotely.Desktop.Shared.Enums.ButtonAction.PrivacyOn + : Remotely.Desktop.Shared.Enums.ButtonAction.PrivacyOff + }; + + // Send to the current desktop (screen-caster) connection. + return _desktopHubRaw.Clients + .Client(SessionInfo.DesktopConnectionId) + .SendAsync("ReceiveButtonAction", msg); + } + + private string RequesterDisplayName { get @@ -49,10 +79,7 @@ private string RequesterDisplayName } return string.Empty; } - set - { - Context.Items[nameof(RequesterDisplayName)] = value; - } + set => Context.Items[nameof(RequesterDisplayName)] = value; } private RemoteControlSession SessionInfo @@ -69,11 +96,9 @@ private RemoteControlSession SessionInfo Context.Items[nameof(SessionInfo)] = newSession; return newSession; } - set - { - Context.Items[nameof(SessionInfo)] = value; - } + set => Context.Items[nameof(SessionInfo)] = value; } + public async Task ChangeWindowsSession(int targetWindowsSession) { try @@ -195,6 +220,7 @@ public Task SendDtoToClient(byte[] dtoWrapper) .Client(SessionInfo.DesktopConnectionId) .SendDtoToClient(dtoWrapper, Context.ConnectionId); } + public async Task SendScreenCastRequestToDevice(string sessionId, string accessKey, string requesterName) { if (string.IsNullOrWhiteSpace(sessionId)) @@ -231,14 +257,14 @@ public async Task SendScreenCastRequestToDevice(string sessionId, string } var logMessage = $"Remote control session requested. " + - $"Login ID (if logged in): {Context.User?.Identity?.Name}. " + - $"Machine Name: {SessionInfo.MachineName}. " + - $"Stream ID: {SessionInfo.StreamId}. " + - $"Requester Name (if specified): {RequesterDisplayName}. " + - $"Connection ID: {Context.ConnectionId}. User ID: {Context.UserIdentifier}. " + - $"Screen Caster Connection ID: {SessionInfo.DesktopConnectionId}. " + - $"Mode: {SessionInfo.Mode}. " + - $"Requester IP Address: {Context.GetHttpContext()?.Connection?.RemoteIpAddress}"; + $"Login ID (if logged in): {Context.User?.Identity?.Name}. " + + $"Machine Name: {SessionInfo.MachineName}. " + + $"Stream ID: {SessionInfo.StreamId}. " + + $"Requester Name (if specified): {RequesterDisplayName}. " + + $"Connection ID: {Context.ConnectionId}. User ID: {Context.UserIdentifier}. " + + $"Screen Caster Connection ID: {SessionInfo.DesktopConnectionId}. " + + $"Mode: {SessionInfo.Mode}. " + + $"Requester IP Address: {Context.GetHttpContext()?.Connection?.RemoteIpAddress}"; _logger.LogInformation("{msg}", logMessage); @@ -301,5 +327,4 @@ public async Task StoreSessionRecording(IAsyncEnumerable webmStream) _logger.LogError(ex, "Error while storing session recording for stream {streamId}.", SessionInfo.StreamId); } } - } diff --git a/Server/Pages/Viewer.cshtml b/Server/Pages/Viewer.cshtml index 1c175f6af..170edd749 100644 --- a/Server/Pages/Viewer.cshtml +++ b/Server/Pages/Viewer.cshtml @@ -27,17 +27,18 @@ + + + + + + + + + + + - - - - - - - - - -
@@ -86,6 +87,12 @@ Invite Others + +