Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ sha2 = "0.10.8"
[target.'cfg(unix)'.dependencies]
libc = "0.2.148"

[target.'cfg(windows)'.dependencies]
windows-service = "0.7.0"
windows = { version = "0.61.2", features = [
"Win32_Security",
"Win32_System_Services",
"Win32_System_Registry",
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_System_EventLog",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Threading",
"Win32_System_RemoteDesktop",
"Win32_System_Environment",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Console",
] }

[features]
default = [
"gtk",
Expand Down
4 changes: 4 additions & 0 deletions input-emulation/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ windows = { version = "0.61.2", features = [
"Win32_Graphics_Gdi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_System_StationsAndDesktops",
"Win32_System_RemoteDesktop",
"Win32_System_Shutdown",
"Win32_Security",
] }

[features]
Expand Down
118 changes: 113 additions & 5 deletions input-emulation/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
})
}
}

Expand Down Expand Up @@ -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(());
}
Comment on lines +95 to +99
Copy link

Copilot AI Feb 13, 2026

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.

Copilot uses AI. Check for mistakes.

match state {
// pressed
0 => self.kill_repeat_task(),
Expand Down Expand Up @@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From win32 doc:

The function returns the number of events that it successfully inserted into the keyboard or mouse input stream. If the function returns zero, the input was already blocked by another thread. To get extended error information, call GetLastError.

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);
}
}

Expand Down Expand Up @@ -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);
}
}
Copy link
Contributor Author

@jonstelly jonstelly Feb 13, 2026

Choose a reason for hiding this comment

The 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

}
137 changes: 137 additions & 0 deletions run.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
}
Loading
Loading