-
-
Notifications
You must be signed in to change notification settings - Fork 197
feat: windows service for uac+login support #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f8b90bf
58e04c5
7fec0a4
831987c
255aa40
bd9f416
bc7d28a
fb31483
684a829
9ddc443
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,11 @@ use async_trait::async_trait; | |
| use std::ops::BitOrAssign; | ||
| use std::time::Duration; | ||
| use tokio::task::AbortHandle; | ||
| use windows::Win32::System::StationsAndDesktops::{ | ||
| CloseDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, GetThreadDesktop, OpenInputDesktop, | ||
| SetThreadDesktop, | ||
| }; | ||
| use windows::Win32::System::Threading::GetCurrentThreadId; | ||
| use windows::Win32::UI::Input::KeyboardAndMouse::{ | ||
| INPUT, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, | ||
| MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, | ||
|
|
@@ -21,16 +26,35 @@ use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; | |
|
|
||
| use super::{Emulation, EmulationHandle}; | ||
|
|
||
| // Desktop access rights for input injection | ||
| // GENERIC_WRITE (0x40000000) + DESKTOP_CREATEWINDOW (0x0002) + DESKTOP_HOOKCONTROL (0x0008) | ||
| // DF_ALLOWOTHERACCOUNTHOOK (0x0001) allows accessing desktops owned by other accounts | ||
| const GENERIC_WRITE: u32 = 0x40000000; | ||
| const DESKTOP_CREATEWINDOW: u32 = 0x0002; | ||
| const DESKTOP_HOOKCONTROL: u32 = 0x0008; | ||
| const DF_ALLOWOTHERACCOUNTHOOK: u32 = 0x0001; | ||
| const DESKTOP_ACCESS_FOR_INPUT: u32 = DESKTOP_CREATEWINDOW | DESKTOP_HOOKCONTROL | GENERIC_WRITE; | ||
|
|
||
| const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500); | ||
| const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32); | ||
|
|
||
| // Linux keycodes for modifier tracking | ||
| const KEY_LEFT_META: u32 = 125; | ||
| const KEY_RIGHT_META: u32 = 126; | ||
| // Linux keycode for L | ||
| const KEY_L: u32 = 38; | ||
|
|
||
| pub(crate) struct WindowsEmulation { | ||
| repeat_task: Option<AbortHandle>, | ||
| meta_pressed: bool, | ||
| } | ||
|
|
||
| impl WindowsEmulation { | ||
| pub(crate) fn new() -> Result<Self, WindowsEmulationCreationError> { | ||
| Ok(Self { repeat_task: None }) | ||
| Ok(Self { | ||
| repeat_task: None, | ||
| meta_pressed: false, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -60,6 +84,20 @@ impl Emulation for WindowsEmulation { | |
| key, | ||
| state, | ||
| } => { | ||
| // Track Meta/Super key state | ||
| if key == KEY_LEFT_META || key == KEY_RIGHT_META { | ||
| self.meta_pressed = state == 1; | ||
| } | ||
|
|
||
| // Intercept Win+L: LockWorkStation() cannot be triggered | ||
| // via SendInput because Windows blocks it as a Secure | ||
| // Attention Sequence. Instead we lock the session directly. | ||
| if key == KEY_L && state == 1 && self.meta_pressed { | ||
| log::info!("Win+L detected, locking workstation"); | ||
| lock_workstation(); | ||
| return Ok(()); | ||
| } | ||
|
|
||
| match state { | ||
| // pressed | ||
| 0 => self.kill_repeat_task(), | ||
|
|
@@ -103,14 +141,51 @@ impl WindowsEmulation { | |
| } | ||
| } | ||
|
|
||
| /// Send input with desktop switching to handle UAC prompts and other secure desktops. | ||
| /// When running in a user session (spawned by the Windows service), this allows | ||
| /// input injection on the Secure Desktop (UAC prompts) by temporarily switching | ||
| /// to the current input desktop. | ||
| fn send_input_safe(input: INPUT) { | ||
| unsafe { | ||
| loop { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was this loop to handle some flakiness of SendInput? Seems like could get stuck if SendInput fails and never succeeds from simple retry? Should it be added back, or switched to something to try X times before logging error and giving up?
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From win32 doc:
So I did this to make sure the event gets submitted eventually. |
||
| /* retval = number of successfully submitted events */ | ||
| if SendInput(&[input], std::mem::size_of::<INPUT>() as i32) > 0 { | ||
| break; | ||
| // Try to open the current input desktop (may be Secure Desktop during UAC) | ||
| // This only works when running in the user's session, not from Session 0 | ||
| let input_desktop = match OpenInputDesktop( | ||
| DESKTOP_CONTROL_FLAGS(DF_ALLOWOTHERACCOUNTHOOK), | ||
| true, // fInherit | ||
| DESKTOP_ACCESS_FLAGS(DESKTOP_ACCESS_FOR_INPUT), | ||
| ) { | ||
| Ok(desktop) => desktop, | ||
| Err(e) => { | ||
| // Desktop switching not available - fall back to direct SendInput | ||
| // This works for normal desktop but won't reach UAC/login screen | ||
| log::debug!("OpenInputDesktop failed: {} - using direct SendInput", e); | ||
| SendInput(&[input], std::mem::size_of::<INPUT>() as i32); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // Save current desktop, switch to input desktop, send input, restore | ||
| let old_desktop = GetThreadDesktop(GetCurrentThreadId()); | ||
|
|
||
| if SetThreadDesktop(input_desktop).is_err() { | ||
| log::warn!("SetThreadDesktop failed, using direct SendInput"); | ||
| let _ = CloseDesktop(input_desktop); | ||
| SendInput(&[input], std::mem::size_of::<INPUT>() as i32); | ||
| return; | ||
| } | ||
|
|
||
| let count = SendInput(&[input], std::mem::size_of::<INPUT>() as i32); | ||
| if count == 0 { | ||
| log::warn!("SendInput failed after desktop switch"); | ||
| } | ||
|
|
||
| // Restore original desktop | ||
| if let Ok(desktop) = old_desktop { | ||
| if !desktop.is_invalid() { | ||
| let _ = SetThreadDesktop(desktop); | ||
| } | ||
| } | ||
| let _ = CloseDesktop(input_desktop); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -235,3 +310,36 @@ fn linux_keycode_to_windows_scancode(linux_keycode: u32) -> Option<u16> { | |
| log::trace!("windows code: {windows_scancode:?}"); | ||
| Some(windows_scancode as u16) | ||
| } | ||
|
|
||
| /// Lock the workstation. | ||
| /// | ||
| /// `Win+L` is a Secure Attention Sequence that Windows blocks from being | ||
| /// injected via `SendInput`. When running inside a user session (Session != 0) | ||
| /// we can call `LockWorkStation()` which is the documented public API. | ||
| /// | ||
| /// When running in Session 0 (the service session) there is no interactive | ||
| /// desktop to lock, so we disconnect the console session via | ||
| /// `WTSDisconnectSession` which achieves the same visible effect (returns to | ||
| /// the lock / login screen). | ||
| fn lock_workstation() { | ||
| // Try the simple path first — works when we are in the user's session. | ||
| unsafe { | ||
| use windows::Win32::System::Shutdown::LockWorkStation; | ||
| if LockWorkStation().is_ok() { | ||
| log::info!("LockWorkStation succeeded"); | ||
| return; | ||
| } | ||
| log::warn!("LockWorkStation failed, trying WTSDisconnectSession"); | ||
|
|
||
| // Fallback for Session 0: disconnect the active console session. | ||
| use windows::Win32::System::RemoteDesktop::{ | ||
| WTS_CURRENT_SERVER_HANDLE, WTSDisconnectSession, WTSGetActiveConsoleSessionId, | ||
| }; | ||
| let session_id = WTSGetActiveConsoleSessionId(); | ||
| if WTSDisconnectSession(Some(WTS_CURRENT_SERVER_HANDLE), session_id, true).is_err() { | ||
| log::error!("WTSDisconnectSession also failed"); | ||
| } else { | ||
| log::info!("WTSDisconnectSession succeeded (session {})", session_id); | ||
| } | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have tested this on windows running lan-mouse as a service and as normal non-service process, locking workstation works in both cases |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| #!/usr/bin/env pwsh | ||
| <# | ||
| .SYNOPSIS | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was really just a helper to script the build, service stop, file copy, service start... mainly to simplify my development workflow of testing the windows service on a remote windows machine so I could do all that in a 1-liner at an elevated terminal... I'm happy to remove this if you'd rather it not in the repo. My usage was: ./run.ps1 -Build -Service |
||
| Build and run lan-mouse (as service or directly) | ||
| .DESCRIPTION | ||
| Optionally builds lan-mouse in debug mode, stops any running service, copies binaries to target/svc/, | ||
| then either starts the Windows service or runs the executable directly. | ||
| .PARAMETER Build | ||
| Build lan-mouse before deployment | ||
| .PARAMETER Service | ||
| Start the lan-mouse Windows service after deployment | ||
| .PARAMETER Direct | ||
| Run ./target/svc/lan-mouse.exe directly (not as a service) | ||
| .PARAMETER Install | ||
| If specified with -Service, registers the service via 'lan-mouse install' before starting | ||
| .PARAMETER Clean | ||
| Truncate all log files in C:\ProgramData\lan-mouse\ before starting | ||
| .EXAMPLE | ||
| .\run.ps1 -Build -Service | ||
| .\run.ps1 -Build -Service -Install | ||
| .\run.ps1 -Build -Direct | ||
| .\run.ps1 -Direct | ||
| .\run.ps1 -Clean -Service | ||
| #> | ||
|
|
||
| param( | ||
| [switch]$Build, | ||
| [switch]$Service, | ||
| [switch]$Direct, | ||
| [switch]$Install, | ||
| [switch]$Clean | ||
| ) | ||
|
|
||
| if (-not $Service -and -not $Direct) { | ||
| Write-Host "Error: You must specify either -Service or -Direct" -ForegroundColor Red | ||
| Write-Host " -Service Start the lan-mouse Windows service" | ||
| Write-Host " -Direct Run the executable directly" | ||
| exit 1 | ||
| } | ||
|
|
||
| if ($Service -and $Direct) { | ||
| Write-Host "Error: Cannot specify both -Service and -Direct" -ForegroundColor Red | ||
| exit 1 | ||
| } | ||
|
|
||
| $ErrorActionPreference = "Stop" | ||
|
|
||
| # Change to repository root | ||
| $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path | ||
| Push-Location $ScriptDir | ||
|
|
||
| try { | ||
| if ($Build) { | ||
| Write-Host "Building lan-mouse (debug, no default features)..." -ForegroundColor Cyan | ||
| cargo build --no-default-features | ||
| if ($LASTEXITCODE -ne 0) { | ||
| throw "Build failed with exit code $LASTEXITCODE" | ||
| } | ||
| } | ||
|
|
||
| Write-Host "`nStopping lan-mouse service..." -ForegroundColor Cyan | ||
| $svcInfo = Get-Service -Name "lan-mouse" -ErrorAction SilentlyContinue | ||
| if ($svcInfo -and $svcInfo.Status -eq "Running") { | ||
| try { | ||
| Stop-Service -Name "lan-mouse" -Force -ErrorAction Stop | ||
| Write-Host "Service stopped" -ForegroundColor Green | ||
| } catch { | ||
| Write-Host "Stop-Service failed: $($_.Exception.Message)" -ForegroundColor Yellow | ||
| Write-Host "Attempting to kill lan-mouse process..." -ForegroundColor Yellow | ||
| $proc = Get-Process -Name "lan-mouse" -ErrorAction SilentlyContinue | ||
| if ($proc) { | ||
| $proc | Stop-Process -Force | ||
| Write-Host "Process killed" -ForegroundColor Green | ||
| } else { | ||
| Write-Host "No lan-mouse process found" -ForegroundColor Yellow | ||
| } | ||
| } | ||
| } elseif ($svcInfo) { | ||
| Write-Host "Service exists but not running (status: $($svcInfo.Status))" -ForegroundColor Yellow | ||
| # Still check for orphan process | ||
| $proc = Get-Process -Name "lan-mouse" -ErrorAction SilentlyContinue | ||
| if ($proc) { | ||
| Write-Host "Found orphan lan-mouse process, killing..." -ForegroundColor Yellow | ||
| $proc | Stop-Process -Force | ||
| Write-Host "Process killed" -ForegroundColor Green | ||
| } | ||
| } else { | ||
| Write-Host "Service not registered (will install if -Install is used)" -ForegroundColor Yellow | ||
| } | ||
|
|
||
| Write-Host "`nCopying binaries to target/svc/..." -ForegroundColor Cyan | ||
| $SvcDir = Join-Path $ScriptDir "target\svc" | ||
| if (-not (Test-Path $SvcDir)) { | ||
| New-Item -ItemType Directory -Path $SvcDir | Out-Null | ||
| } | ||
|
|
||
| Copy-Item -Path "target\debug\*" -Destination $SvcDir -Recurse -Force | ||
| Write-Host "Binaries copied" -ForegroundColor Green | ||
|
|
||
| if ($Clean) { | ||
| Write-Host "`nTruncating log files in C:\ProgramData\lan-mouse\..." -ForegroundColor Cyan | ||
| $LogDir = "C:\ProgramData\lan-mouse" | ||
| Get-ChildItem -Path $LogDir -Filter "*.log" -ErrorAction SilentlyContinue | ForEach-Object { | ||
| Clear-Content -Path $_.FullName -ErrorAction SilentlyContinue | ||
| Write-Host " Truncated: $($_.Name)" -ForegroundColor Gray | ||
| } | ||
| } | ||
|
|
||
| if ($Service) { | ||
| if ($Install) { | ||
| Write-Host "`nInstalling service..." -ForegroundColor Cyan | ||
| $ServiceExe = Join-Path $SvcDir "lan-mouse.exe" | ||
| & $ServiceExe install | ||
| if ($LASTEXITCODE -ne 0) { | ||
| throw "Service installation failed with exit code $LASTEXITCODE" | ||
| } | ||
| Write-Host "Service installed" -ForegroundColor Green | ||
| } | ||
|
|
||
| Write-Host "`nStarting lan-mouse service..." -ForegroundColor Cyan | ||
| Start-Service -Name "lan-mouse" | ||
| Write-Host "Service started" -ForegroundColor Green | ||
|
|
||
| Write-Host "`nDeployment complete!" -ForegroundColor Green | ||
| Write-Host "`nTailing service log (Ctrl+C to stop)..." -ForegroundColor Cyan | ||
| Get-Content -Wait -Tail 20 "C:\ProgramData\lan-mouse\winsvc.log" | ||
| } elseif ($Direct) { | ||
| Write-Host "`nRunning lan-mouse directly..." -ForegroundColor Cyan | ||
| $ServiceExe = Join-Path $SvcDir "lan-mouse.exe" | ||
| & $ServiceExe | ||
| } | ||
| } catch { | ||
| Write-Host "`nError: $_" -ForegroundColor Red | ||
| exit 1 | ||
| } finally { | ||
| Pop-Location | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Win+L is intercepted and lock_workstation() is called, the function returns early without calling key_event() or spawning the repeat task. This means the L key press is never sent to the system. However, this also means the Meta key state remains pressed in the emulation layer, which could cause issues if the user releases Meta after locking. Consider whether the Meta key should also be released or if this behavior is intentional.