ALL ARTICLES

I Turned KDE Connect Into a Remote Control for My Windows PC

How I turned KDE Connect into a verified remote-control and monitoring setup for Windows using Tailscale, PowerShell, and Task Scheduler.

13 MIN READBY CEMİL İLKİM TEKE
Android phone using KDE Connect to remotely control a Windows PC over Tailscale.

I Turned KDE Connect Into a Remote Control for My Windows PC

This whole setup started because of one very specific thought:

Wait. Did I lock my PC?

If you have ever left home or the office and suddenly remembered your computer sitting there with the screen unlocked, you know how annoying that question is.

I already had KDE Connect installed, so my first idea was simple: add a remote lock command and call it done.

That worked.

Then I realized I had no way to know whether the command actually reached the computer.

So I added a confirmation notification.

Then I added Tailscale so the same setup would work while my phone was on 5G and the PC was on a completely different network.

After that came restart, shutdown, status reports, battery warnings, Wi-Fi monitoring, disk alerts, and CPU/RAM checks.

A tiny convenience feature slowly turned into a lightweight remote control and monitoring system for my Windows PC.

This post is the complete version of the setup. You should be able to start with a normal KDE Connect installation and build the whole thing from here without needing any other scripts.

What we are building#

The finished setup can:

  • lock the PC remotely
  • confirm when Windows actually enters the locked state
  • notify the phone when the PC is unlocked
  • restart or shut down the PC with a 30-second safety delay
  • cancel a pending restart or shutdown
  • send a quick PC status report
  • report when the PC comes online after login
  • distinguish a remote restart or shutdown from a normal login
  • warn when the charger is connected or disconnected
  • warn when the battery drops below 20 percent or 10 percent
  • detect Wi-Fi disconnects and recovery
  • warn about low disk space
  • warn about sustained high RAM or CPU usage
  • queue notifications while the phone is unreachable and send them later

The stack is fairly small:

  • KDE Connect handles remote commands and phone notifications
  • Tailscale makes the phone and PC reachable from different networks
  • PowerShell handles the Windows-side automation
  • Task Scheduler reacts to Windows events and keeps the monitoring agent running

The architecture looks roughly like this:

Android phone
     |
     | 5G / Wi-Fi
     |
 Tailscale
     |
 Windows PC
     |
 KDE Connect
     |
 PowerShell + Task Scheduler

The important design choice is that KDE Connect itself is not exposed directly to the public internet.


1. Install and test KDE Connect#

Install KDE Connect on both the Windows PC and Android phone.

Pair them normally while both devices are on the same local network.

Before doing anything else, make sure the normal KDE Connect features work. Ping, clipboard sharing, file transfer, and Run Commands are good tests.

On Windows, open PowerShell and run:

kdeconnect-cli -a --id-only

You should get a device ID similar to:

YOUR_PHONE_DEVICE_ID

Save that value. We will use it later when the PC needs to send notifications back to the phone.

The weird Qt warning on Windows#

On my Windows installation, kdeconnect-cli also printed this:

QEventDispatcherWin32::wakeUp: Failed to post a message (Invalid window handle.)

At first I assumed the command had failed.

It had not.

This still sent a notification to my phone:

kdeconnect-cli -d YOUR_PHONE_DEVICE_ID --ping-msg "Test notification"

The terminal complained, but the notification arrived.

So if you see that exact warning, verify the actual result before debugging it. In the scripts below I intentionally suppress that stderr noise.


2. Add Tailscale for remote access#

KDE Connect is already useful when both devices are on the same network. The goal here is to keep using it when the phone and PC are nowhere near each other.

Install Tailscale on both devices and sign into the same tailnet.

The PC will receive a Tailscale IP that usually looks like:

100.x.x.x

Open KDE Connect on Android, open the three-dot menu on the Devices screen, choose Add devices by IP, and enter the PC's Tailscale IP.

Test it properly#

Do not test this while both devices are still on the same Wi-Fi, because that proves nothing.

Use this test:

  1. Leave the Windows PC connected to Wi-Fi.
  2. Disable Wi-Fi on the phone.
  3. Switch the phone to 4G or 5G.
  4. Keep Tailscale enabled.
  5. Open KDE Connect and check whether the PC is reachable.
  6. Send a ping or run a harmless command.

If that works, KDE Connect is now usable outside the local network.

I keep Tailscale enabled without an exit node. In that configuration, normal internet traffic still uses my regular Wi-Fi or mobile connection, while traffic to my tailnet devices goes through Tailscale.

Conceptually:

Phone -> normal website

uses the normal internet connection.

This:

Phone -> Tailscale -> Windows PC

uses the private Tailscale connection.

If you also use another VPN on Android, your phone may only keep one VPN interface active at a time. In my case, enabling Proton VPN disconnects Tailscale. When I am done with Proton, I enable Tailscale again and KDE Connect continues working.

If KDE Connect cannot reach the PC through Tailscale#

First confirm that the phone can see the PC inside Tailscale.

If Tailscale connectivity itself works but KDE Connect still cannot connect, check Windows Defender Firewall and make sure KDE Connect is allowed. KDE Connect uses dynamic TCP and UDP ports in the 1714-1764 range.

Do not solve this by exposing those ports directly on your router. The whole point of using Tailscale here is to avoid putting KDE Connect directly on the public internet.


3. Create the script directory#

I keep the entire setup under:

C:\Scripts\KDEConnectRemote

Open PowerShell and create it:

New-Item -ItemType Directory -Force C:\Scripts\KDEConnectRemote

Everything below will live inside that folder.


4. Create the configuration file#

Create:

C:\Scripts\KDEConnectRemote\config.json

with:

{
  "DeviceId": "YOUR_PHONE_DEVICE_ID",
  "BatteryLowPercent": 20,
  "BatteryCriticalPercent": 10,
  "DiskLowGB": 15,
  "RamHighPercent": 90,
  "CpuHighPercent": 95,
  "SustainedMinutes": 5
}

Replace:

YOUR_PHONE_DEVICE_ID

with the device ID you got from:

kdeconnect-cli -a --id-only

The remaining values are monitoring thresholds. You can change them later without touching the monitoring script.


5. Create the notification helper#

This script is the common notification layer used by everything else.

Create:

C:\Scripts\KDEConnectRemote\Notify-KDE.ps1

with:

param(
    [Parameter(Mandatory = $true, Position = 0)]
    [string]$Message,

    [int]$WaitSeconds = 0,

    [switch]$NoQueue
)

$ErrorActionPreference = "Continue"

$Root = "C:\Scripts\KDEConnectRemote"
$Config = Get-Content (Join-Path $Root "config.json") -Raw | ConvertFrom-Json
$QueueFile = Join-Path $Root "notification-queue.ndjson"

$Cli = (Get-Command kdeconnect-cli.exe -ErrorAction SilentlyContinue).Source

if (-not $Cli) {
    $Fallback = "C:\Program Files\KDE Connect\bin\kdeconnect-cli.exe"

    if (Test-Path $Fallback) {
        $Cli = $Fallback
    }
}

if (-not $Cli) {
    exit 2
}

function Test-PhoneReachable {
    try {
        $Ids = @(
            & $Cli -a --id-only 2>$null |
            ForEach-Object { $_.Trim() }
        )

        return $Ids -contains [string]$Config.DeviceId
    }
    catch {
        return $false
    }
}

$Deadline = (Get-Date).AddSeconds(
    [Math]::Max(0, $WaitSeconds)
)

do {
    if (Test-PhoneReachable) {
        & $Cli -d $Config.DeviceId --ping-msg $Message 2>$null | Out-Null
        exit 0
    }

    if ((Get-Date) -ge $Deadline) {
        break
    }

    Start-Sleep -Seconds 2
}
while ($true)

if (-not $NoQueue) {
    $Item = [ordered]@{
        Time    = (Get-Date).ToString("o")
        Message = $Message
    } | ConvertTo-Json -Compress

    Add-Content -LiteralPath $QueueFile -Value $Item -Encoding UTF8
}

exit 1

The useful part here is the queue.

If the phone is temporarily unreachable because Tailscale is disabled or the PC has lost its network connection, the message is stored locally instead of disappearing.

Test the notification helper#

Run:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\KDEConnectRemote\Notify-KDE.ps1 "Test notification"

The phone should receive:

Test notification

Do not continue until this works.


6. Create the notification queue flusher#

Create:

C:\Scripts\KDEConnectRemote\Flush-KDEQueue.ps1

with:

$ErrorActionPreference = "Continue"

$Root = "C:\Scripts\KDEConnectRemote"
$Config = Get-Content (Join-Path $Root "config.json") -Raw | ConvertFrom-Json
$QueueFile = Join-Path $Root "notification-queue.ndjson"

if (-not (Test-Path $QueueFile)) {
    exit 0
}

$Cli = (Get-Command kdeconnect-cli.exe -ErrorAction SilentlyContinue).Source

if (-not $Cli) {
    $Fallback = "C:\Program Files\KDE Connect\bin\kdeconnect-cli.exe"

    if (Test-Path $Fallback) {
        $Cli = $Fallback
    }
}

if (-not $Cli) {
    exit 2
}

try {
    $Ids = @(
        & $Cli -a --id-only 2>$null |
        ForEach-Object { $_.Trim() }
    )
}
catch {
    exit 1
}

if ($Ids -notcontains [string]$Config.DeviceId) {
    exit 1
}

$Lines = @(
    Get-Content -LiteralPath $QueueFile -ErrorAction SilentlyContinue
)

if ($Lines.Count -eq 0) {
    Remove-Item $QueueFile -Force -ErrorAction SilentlyContinue
    exit 0
}

Remove-Item $QueueFile -Force -ErrorAction SilentlyContinue

foreach ($Line in $Lines) {
    if ([string]::IsNullOrWhiteSpace($Line)) {
        continue
    }

    try {
        $Item = $Line | ConvertFrom-Json
        $Time = [datetime]::Parse([string]$Item.Time)
        $Age = (Get-Date) - $Time

        if ($Age.TotalMinutes -ge 2) {
            $Message = "$($Item.Message) (at $($Time.ToString('HH:mm')))"
        }
        else {
            $Message = [string]$Item.Message
        }

        & $Cli -d $Config.DeviceId --ping-msg $Message 2>$null | Out-Null

        Start-Sleep -Milliseconds 300
    }
    catch {}
}

If a notification sits in the queue for more than two minutes, the original time is included when it is finally delivered.

That is especially useful for network outages.


7. Create the lock and unlock notification script#

Create:

C:\Scripts\KDEConnectRemote\Event-Notify.ps1

with:

param(
    [Parameter(Mandatory = $true)]
    [ValidateSet("Locked", "Unlocked")]
    [string]$Event
)

$Root = "C:\Scripts\KDEConnectRemote"

switch ($Event) {
    "Locked" {
        $Message = "PC locked"
    }

    "Unlocked" {
        $Message = "PC unlocked"
    }
}

& (Join-Path $Root "Notify-KDE.ps1") $Message | Out-Null

This script does not decide when the PC is locked or unlocked. Windows will do that for us through Task Scheduler.

That distinction matters because the PC locked notification will be triggered by the real Windows workstation lock event.


8. Create the remote control script#

Create:

C:\Scripts\KDEConnectRemote\Remote-Control.ps1

with:

param(
    [Parameter(Mandatory = $true, Position = 0)]
    [ValidateSet(
        "Lock",
        "Shutdown30",
        "Restart30",
        "CancelPower",
        "Status"
    )]
    [string]$Action
)

$Root = "C:\Scripts\KDEConnectRemote"
$NotifyScript = Join-Path $Root "Notify-KDE.ps1"
$Marker = Join-Path $Root "power-action.json"

function Send-Notice {
    param([string]$Message)

    & $NotifyScript $Message | Out-Null
}

function Write-PowerMarker {
    param([string]$Type)

    [ordered]@{
        Action = $Type
        Time   = (Get-Date).ToString("o")
    } |
        ConvertTo-Json |
        Set-Content -LiteralPath $Marker -Encoding UTF8
}

switch ($Action) {
    "Lock" {
        rundll32.exe user32.dll,LockWorkStation
    }

    "Shutdown30" {
        shutdown.exe /s /t 30

        if ($LASTEXITCODE -eq 0) {
            Write-PowerMarker "shutdown"
            Send-Notice "PC will shut down in 30 seconds"
        }
        else {
            Send-Notice "Shutdown request failed"
        }
    }

    "Restart30" {
        shutdown.exe /r /t 30

        if ($LASTEXITCODE -eq 0) {
            Write-PowerMarker "restart"
            Send-Notice "PC will restart in 30 seconds"
        }
        else {
            Send-Notice "Restart request failed"
        }
    }

    "CancelPower" {
        shutdown.exe /a

        if ($LASTEXITCODE -eq 0) {
            Remove-Item -LiteralPath $Marker -Force -ErrorAction SilentlyContinue
            Send-Notice "Pending shutdown/restart cancelled"
        }
        else {
            Send-Notice "No pending shutdown/restart to cancel"
        }
    }

    "Status" {
        $Parts = New-Object System.Collections.Generic.List[string]

        try {
            $Battery = Get-CimInstance Win32_Battery -ErrorAction Stop |
                Select-Object -First 1

            if ($Battery) {
                $Parts.Add(
                    "Battery $($Battery.EstimatedChargeRemaining)%"
                )
            }
        }
        catch {}

        try {
            $OS = Get-CimInstance Win32_OperatingSystem

            $RamUsed = [math]::Round(
                (
                    (
                        $OS.TotalVisibleMemorySize -
                        $OS.FreePhysicalMemory
                    ) /
                    $OS.TotalVisibleMemorySize
                ) * 100
            )

            $Parts.Add("RAM $RamUsed%")

            $Uptime = (Get-Date) - $OS.LastBootUpTime

            if ($Uptime.TotalDays -ge 1) {
                $Parts.Add(
                    "Uptime $([math]::Floor($Uptime.TotalDays))d $($Uptime.Hours)h"
                )
            }
            else {
                $Parts.Add(
                    "Uptime $($Uptime.Hours)h $($Uptime.Minutes)m"
                )
            }
        }
        catch {}

        try {
            $Drive = Get-PSDrive C
            $FreeGB = [math]::Round($Drive.Free / 1GB, 1)

            $Parts.Add("C: $FreeGB GB free")
        }
        catch {}

        try {
            $WiFi = Get-NetAdapter -ErrorAction SilentlyContinue |
                Where-Object {
                    $_.Name -match "Wi-Fi|WLAN" -or
                    $_.InterfaceDescription -match "Wireless|802\.11"
                } |
                Select-Object -First 1

            if ($WiFi) {
                $Parts.Add("Wi-Fi $($WiFi.Status)")
            }
        }
        catch {}

        if ($Parts.Count -eq 0) {
            Send-Notice "PC is online"
        }
        else {
            Send-Notice (
                "PC online | " + ($Parts -join " | ")
            )
        }
    }
}

The shutdown and restart commands deliberately use a 30-second delay.

That gives you enough time to cancel an accidental press.

One warning: Windows can force applications to close when a timed shutdown reaches zero, so do not treat this as a safe way to preserve unsaved work.


9. Create the monitoring script#

Create:

C:\Scripts\KDEConnectRemote\Monitor-Once.ps1

with:

$Root = "C:\Scripts\KDEConnectRemote"
$Config = Get-Content (Join-Path $Root "config.json") -Raw | ConvertFrom-Json
$NotifyScript = Join-Path $Root "Notify-KDE.ps1"
$StateFile = Join-Path $Root "monitor-state.json"

function Send-Notice {
    param([string]$Message)

    & $NotifyScript $Message | Out-Null
}

$State = @{}

if (Test-Path $StateFile) {
    try {
        $Object = Get-Content $StateFile -Raw | ConvertFrom-Json

        foreach ($Property in $Object.PSObject.Properties) {
            $State[$Property.Name] = $Property.Value
        }
    }
    catch {}
}

function Get-State {
    param(
        [string]$Key,
        $Default
    )

    if ($State.ContainsKey($Key)) {
        return $State[$Key]
    }

    return $Default
}

# Try to deliver notifications that were queued while the phone
# or network was unavailable.
& (Join-Path $Root "Flush-KDEQueue.ps1") | Out-Null

# ---------------------------------------------------------
# Battery and charger
# ---------------------------------------------------------

try {
    $BatteryDevice = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue |
        Select-Object -First 1

    if ($BatteryDevice) {
        Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue

        $Power = [System.Windows.Forms.SystemInformation]::PowerStatus
        $PowerLine = $Power.PowerLineStatus.ToString()
        $ACKnown = $PowerLine -ne "Unknown"
        $ACOnline = $PowerLine -eq "Online"
        $BatteryPercent = [int]$BatteryDevice.EstimatedChargeRemaining

        if ($ACKnown) {
            $PreviousAC = Get-State "acOnline" $ACOnline

            if ($PreviousAC -eq $true -and $ACOnline -eq $false) {
                Send-Notice "Charger disconnected"
            }
            elseif ($PreviousAC -eq $false -and $ACOnline -eq $true) {
                Send-Notice "Charger connected"
            }

            $State["acOnline"] = $ACOnline
        }

        $BatteryLow = [bool](
            Get-State "batteryLow" $false
        )

        $BatteryCritical = [bool](
            Get-State "batteryCritical" $false
        )

        if (
            -not $ACOnline -and
            $BatteryPercent -lt [int]$Config.BatteryCriticalPercent -and
            -not $BatteryCritical
        ) {
            Send-Notice "Battery critical: $BatteryPercent% remaining"

            $State["batteryCritical"] = $true
            $State["batteryLow"] = $true
        }
        elseif (
            -not $ACOnline -and
            $BatteryPercent -lt [int]$Config.BatteryLowPercent -and
            -not $BatteryLow
        ) {
            Send-Notice "Battery low: $BatteryPercent% remaining"

            $State["batteryLow"] = $true
        }

        if (
            $ACOnline -or
            $BatteryPercent -ge ([int]$Config.BatteryLowPercent + 5)
        ) {
            $State["batteryLow"] = $false
        }

        if (
            $ACOnline -or
            $BatteryPercent -ge ([int]$Config.BatteryCriticalPercent + 5)
        ) {
            $State["batteryCritical"] = $false
        }
    }
}
catch {}

# ---------------------------------------------------------
# Wi-Fi
# ---------------------------------------------------------

try {
    $WiFi = Get-NetAdapter -ErrorAction SilentlyContinue |
        Where-Object {
            $_.Name -match "Wi-Fi|WLAN" -or
            $_.InterfaceDescription -match "Wireless|802\.11"
        } |
        Select-Object -First 1

    if ($WiFi) {
        $WiFiUp = $WiFi.Status -eq "Up"
        $PreviousWiFi = Get-State "wifiUp" $WiFiUp

        if (
            $PreviousWiFi -eq $true -and
            $WiFiUp -eq $false
        ) {
            $State["wifiDownAt"] = (Get-Date).ToString("o")

            Send-Notice "Wi-Fi disconnected"
        }
        elseif (
            $PreviousWiFi -eq $false -and
            $WiFiUp -eq $true
        ) {
            $DisconnectedAt = Get-State "wifiDownAt" $null

            if ($DisconnectedAt) {
                try {
                    $DownDate = [datetime]::Parse(
                        [string]$DisconnectedAt
                    )

                    $Minutes = [math]::Max(
                        1,
                        [math]::Round(
                            ((Get-Date) - $DownDate).TotalMinutes
                        )
                    )

                    Send-Notice "Wi-Fi restored after $Minutes minute(s)"
                }
                catch {
                    Send-Notice "Wi-Fi restored"
                }
            }
            else {
                Send-Notice "Wi-Fi restored"
            }

            $State["wifiDownAt"] = $null
        }

        $State["wifiUp"] = $WiFiUp
    }
}
catch {}

# ---------------------------------------------------------
# Disk space
# ---------------------------------------------------------

try {
    $Drive = Get-PSDrive -Name C

    $FreeGB = [math]::Round(
        $Drive.Free / 1GB,
        1
    )

    $TotalGB = [math]::Round(
        ($Drive.Used + $Drive.Free) / 1GB,
        1
    )

    if ($TotalGB -gt 0) {
        $FreePercent = [math]::Round(
            ($FreeGB / $TotalGB) * 100
        )
    }
    else {
        $FreePercent = 100
    }

    $DiskLow = [bool](
        Get-State "diskLow" $false
    )

    if (
        (
            $FreeGB -lt [double]$Config.DiskLowGB -or
            $FreePercent -lt 10
        ) -and
        -not $DiskLow
    ) {
        Send-Notice "Low disk space: C: has $FreeGB GB free ($FreePercent%)"

        $State["diskLow"] = $true
    }
    elseif (
        $FreeGB -gt ([double]$Config.DiskLowGB + 5) -and
        $FreePercent -gt 12
    ) {
        $State["diskLow"] = $false
    }
}
catch {}

# ---------------------------------------------------------
# RAM
# ---------------------------------------------------------

try {
    $OS = Get-CimInstance Win32_OperatingSystem

    $RamPercent = [math]::Round(
        (
            (
                $OS.TotalVisibleMemorySize -
                $OS.FreePhysicalMemory
            ) /
            $OS.TotalVisibleMemorySize
        ) * 100
    )

    $RamCount = [int](
        Get-State "ramHighCount" 0
    )

    $RamAlerted = [bool](
        Get-State "ramAlerted" $false
    )

    if ($RamPercent -ge [int]$Config.RamHighPercent) {
        $RamCount++

        if (
            $RamCount -ge [int]$Config.SustainedMinutes -and
            -not $RamAlerted
        ) {
            Send-Notice "High RAM usage: $RamPercent%"
            $RamAlerted = $true
        }
    }
    else {
        $RamCount = 0

        if (
            $RamPercent -lt ([int]$Config.RamHighPercent - 10)
        ) {
            $RamAlerted = $false
        }
    }

    $State["ramHighCount"] = $RamCount
    $State["ramAlerted"] = $RamAlerted
}
catch {}

# ---------------------------------------------------------
# CPU
# ---------------------------------------------------------

try {
    $CpuAverage = (
        Get-CimInstance Win32_Processor |
        Measure-Object LoadPercentage -Average
    ).Average

    $CpuPercent = [math]::Round($CpuAverage)

    $CpuCount = [int](
        Get-State "cpuHighCount" 0
    )

    $CpuAlerted = [bool](
        Get-State "cpuAlerted" $false
    )

    if ($CpuPercent -ge [int]$Config.CpuHighPercent) {
        $CpuCount++

        if (
            $CpuCount -ge [int]$Config.SustainedMinutes -and
            -not $CpuAlerted
        ) {
            Send-Notice "High CPU usage: $CpuPercent%"
            $CpuAlerted = $true
        }
    }
    else {
        $CpuCount = 0

        if (
            $CpuPercent -lt ([int]$Config.CpuHighPercent - 15)
        ) {
            $CpuAlerted = $false
        }
    }

    $State["cpuHighCount"] = $CpuCount
    $State["cpuAlerted"] = $CpuAlerted
}
catch {}

$State |
    ConvertTo-Json -Depth 5 |
    Set-Content -LiteralPath $StateFile -Encoding UTF8

# Connectivity may have returned during this monitoring pass.
& (Join-Path $Root "Flush-KDEQueue.ps1") | Out-Null

The monitor intentionally remembers previous state.

That prevents things like a 19 percent battery from sending the same warning every minute.

The default thresholds from config.json mean:

  • battery warning below 20 percent
  • critical battery warning below 10 percent
  • low disk warning below 15 GB or 10 percent free
  • RAM warning at 90 percent
  • CPU warning at 95 percent
  • CPU and RAM must remain high for about five checks before alerting

Because the monitor will run once per minute, SustainedMinutes: 5 is roughly a five-minute sustained-load requirement.


10. Create the session agent#

Instead of creating a separate scheduled task for every one-minute health check, I use one small background PowerShell process.

It starts when I log in, sends an online notification, then runs the monitor once per minute.

Create:

C:\Scripts\KDEConnectRemote\Session-Agent.ps1

with:

$Root = "C:\Scripts\KDEConnectRemote"
$Marker = Join-Path $Root "power-action.json"
$NotifyScript = Join-Path $Root "Notify-KDE.ps1"

Start-Sleep -Seconds 8

$Message = "PC is online"

if (Test-Path $Marker) {
    try {
        $MarkerData = Get-Content $Marker -Raw | ConvertFrom-Json

        switch ([string]$MarkerData.Action) {
            "restart" {
                $Message = "PC restarted successfully"
            }

            "shutdown" {
                $Message = "PC started after shutdown"
            }
        }
    }
    catch {}

    Remove-Item -LiteralPath $Marker -Force -ErrorAction SilentlyContinue
}

& $NotifyScript $Message -WaitSeconds 60 | Out-Null

while ($true) {
    try {
        & (Join-Path $Root "Monitor-Once.ps1") | Out-Null
    }
    catch {}

    Start-Sleep -Seconds 60
}

There is one important limitation here.

The agent starts at user logon, not before Windows reaches the login screen. KDE Connect itself normally runs in the user's desktop session, so a restart confirmation may not arrive until you sign back into Windows.

For my setup, that is acceptable.


11. Run everything without flashing terminal windows#

If Task Scheduler launches PowerShell directly, Windows may briefly flash a terminal window when a task runs.

It is harmless, but it looks messy.

I use a tiny VBScript wrapper to launch the PowerShell scripts invisibly.

Create:

C:\Scripts\KDEConnectRemote\RunHidden.vbs

with:

Option Explicit

Dim shell, mode, root, cmd, q

root = "C:\Scripts\KDEConnectRemote\"
q = Chr(34)

Set shell = CreateObject("WScript.Shell")

If WScript.Arguments.Count = 0 Then
    WScript.Quit 1
End If

mode = LCase(WScript.Arguments(0))

Select Case mode

    Case "locked"
        cmd = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & _
              q & root & "Event-Notify.ps1" & q & " -Event Locked"

    Case "unlocked"
        cmd = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & _
              q & root & "Event-Notify.ps1" & q & " -Event Unlocked"

    Case "agent"
        cmd = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " & _
              q & root & "Session-Agent.ps1" & q

    Case Else
        WScript.Quit 1

End Select

shell.Run cmd, 0, True

The second argument to shell.Run is 0, which hides the PowerShell window.

The final True makes the wrapper wait for the PowerShell process instead of immediately detaching from it. This lets Task Scheduler properly track the task.


12. Configure Task Scheduler

This is the event layer of the whole setup.

PowerShell contains the logic, but Task Scheduler tells Windows when that logic should run.

Open Task Scheduler:

taskschd.msc

Use Create Task, not Create Basic Task.

We are going to create three tasks:

  • KDE Remote - PC Locked
  • KDE Remote - PC Unlocked
  • KDE Remote - Session Agent

Task 1: PC locked#

Create a new task.

General#

Use:

Name:
KDE Remote - PC Locked

Select:

Run only when user is logged on

Run with highest privileges is not required.

Trigger#

Open Triggers -> New.

Set:

Begin the task:
On workstation lock

If Task Scheduler offers a user selection, use your Windows account rather than every user.

Make sure the trigger is enabled.

Action#

Open Actions -> New.

Choose:

Start a program

Program:

C:\Windows\System32\wscript.exe

Arguments:

"C:\Scripts\KDEConnectRemote\RunHidden.vbs" locked

Conditions#

Disable:

Start the task only if the computer is on AC power

This is important on laptops. Lock confirmation should not stop working just because the charger is disconnected.

Settings#

Keep the task enabled.

For:

If the task is already running

use:

Do not start a new instance

Save the task.

When Windows actually enters the locked state, this task runs Event-Notify.ps1, which sends:

PC locked

to the phone.

This is the confirmation we actually care about.

Task 2: PC unlocked#

Create another task.

General#

Name:
KDE Remote - PC Unlocked

Again select:

Run only when user is logged on

Trigger#

Use:

On workstation unlock

Action#

Program:

C:\Windows\System32\wscript.exe

Arguments:

"C:\Scripts\KDEConnectRemote\RunHidden.vbs" unlocked

Conditions#

Disable:

Start the task only if the computer is on AC power

Settings#

Use:

If the task is already running:
Do not start a new instance

Save it.

Now unlocking Windows sends:

PC unlocked

to the phone.

Task 3: Session Agent#

This task starts the long-running monitoring agent after you sign into Windows.

Create another task.

General#

Use:

Name:
KDE Remote - Session Agent

Select:

Run only when user is logged on

Trigger#

Create a trigger:

At log on

Choose your own Windows user account.

Action#

Program:

C:\Windows\System32\wscript.exe

Arguments:

"C:\Scripts\KDEConnectRemote\RunHidden.vbs" agent

Conditions#

Disable both:

Start the task only if the computer is on AC power

and:

Stop if the computer switches to battery power

Otherwise Windows can do something impressively stupid: stop the battery monitoring process because the laptop switched to battery power.

Settings#

Use:

If the task is already running:
Do not start a new instance

Most importantly, disable:

Stop the task if it runs longer than:

The Session Agent is supposed to remain alive for the entire login session. If Task Scheduler has a three-day runtime limit enabled, it will eventually kill the monitor for no useful reason.

Save the task.

Start the agent without logging out#

You do not have to restart Windows just to test it.

In Task Scheduler Library, find:

KDE Remote - Session Agent

right-click it, then choose:

Run

The phone should receive:

PC is online

after a few seconds.

Testing the lock and unlock tasks#

Press:

Win + L

The phone should receive:

PC locked

Sign back in.

The phone should receive:

PC unlocked

There should be no terminal window flashing on screen.

If both notifications arrive, the Task Scheduler side is working correctly.


13. Add the KDE Connect Run Commands#

Now add the actual buttons that will appear on the phone.

Open KDE Connect's Run Commands configuration on the Windows PC and create these entries manually.

I recommend adding them through the KDE Connect UI instead of editing its internal configuration file.

I tried automating the internal config once.

Every existing Run Command disappeared.

The backup saved me, but I took the hint.

Lock PC#

Name:

Lock PC

Command:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\KDEConnectRemote\Remote-Control.ps1 Lock

Shut Down in 30s#

Name:

Shut Down in 30s

Command:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\KDEConnectRemote\Remote-Control.ps1 Shutdown30

Restart in 30s#

Name:

Restart in 30s

Command:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\KDEConnectRemote\Remote-Control.ps1 Restart30

Cancel Shutdown / Restart#

Name:

Cancel Shutdown / Restart

Command:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\KDEConnectRemote\Remote-Control.ps1 CancelPower

PC Status#

Name:

PC Status

Command:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\KDEConnectRemote\Remote-Control.ps1 Status

That is my final command list.

I experimented with sign out and hibernation too, but removed them because I never used them.


14. Test every command#

Start with the harmless one.

PC Status#

Press:

PC Status

You should receive something similar to:

PC online | Battery 72% | RAM 36% | Uptime 4h 17m | C: 186.4 GB free | Wi-Fi Up

Lock PC#

Press:

Lock PC

The PC should lock.

A moment later, the phone should receive:

PC locked

Remember that this notification comes from the real Windows workstation lock trigger, not directly from the Run Command.

That is the whole point.

Shutdown and cancel#

Press:

Shut Down in 30s

The phone should receive:

PC will shut down in 30 seconds

Before the timer reaches zero, press:

Cancel Shutdown / Restart

You should receive:

Pending shutdown/restart cancelled

If nothing is currently pending, the command instead sends:

No pending shutdown/restart to cancel

Restart#

When you are actually ready to test a restart, press:

Restart in 30s

You should first receive:

PC will restart in 30 seconds

After Windows restarts and you log back in, the Session Agent reads the marker written before the restart and sends:

PC restarted successfully

The same mechanism lets a remote shutdown produce:

PC started after shutdown

after the next login.

A normal login with no marker sends:

PC is online

15. What the automatic monitor reports#

Once the Session Agent is running, the PC checks its basic state about once per minute.

The notifications are intentionally short.

You may see:

Charger disconnected
Charger connected

Battery low: 19% remaining
Battery critical: 9% remaining

Wi-Fi disconnected
Wi-Fi restored after 7 minute(s)

Low disk space: C: has 12.4 GB free (5%)

High RAM usage: 92%
High CPU usage: 97%

The state file prevents repeated spam.

For example, hitting 19 percent battery does not send another Battery low message every minute.

CPU and RAM also need to remain above their thresholds for several checks before an alert is sent.

That keeps normal short spikes from turning the phone into an alarm system.


16. The funny problem with Wi-Fi monitoring#

There is one unavoidable problem with:

Send me a notification when Wi-Fi disconnects.

If Wi-Fi is the PC's only network connection, the computer can detect that Wi-Fi died.

It just cannot tell you.

Because Wi-Fi died.

The notification helper handles this by queueing the message locally.

When connectivity returns, the queue flusher can send something like:

Wi-Fi disconnected (at 16:42)

followed by:

Wi-Fi restored after 7 minute(s)

If the PC also has another working connection such as Ethernet, the disconnect message can be delivered immediately.

No amount of PowerShell is fixing physics.


17. Check that the monitor is actually running#

Open Task Manager and look at the Details tab.

You should see the hidden PowerShell process used by the Session Agent, and possibly wscript.exe supervising it.

Most of the time its CPU usage should be effectively zero.

You can also inspect:

C:\Scripts\KDEConnectRemote\monitor-state.json

The values in that file change as the monitor runs.

Task Scheduler also shows:

Last Run Time
Last Run Result

for the three tasks.

For the lock and unlock tasks, the run time should match the last time you locked or unlocked Windows.

One thing to remember is that some Windows KDE Connect builds produce the Qt Invalid window handle warning even when a notification is successfully delivered. For this setup, the phone receiving the expected notification is the practical end-to-end success test.


18. Performance impact#

This setup is very lightweight.

The lock and unlock tasks only run when those Windows events occur. Remote control commands only run when you press them. The Session Agent mostly sleeps, wakes up once per minute, reads a few system values, updates a tiny JSON state file, then sleeps again.

On a modern PC, the overhead should be negligible.

A random browser tab is probably doing more work than this entire setup.

If you want even less background activity, change:

Start-Sleep -Seconds 60

inside Session-Agent.ps1 to:

Start-Sleep -Seconds 120

or:

Start-Sleep -Seconds 300

Just remember that battery and monitoring alerts will then be delayed by the same amount.


19. Security notes#

I do not expose KDE Connect ports directly through my router.

Tailscale is the layer that makes the PC remotely reachable.

That keeps the remote access surface inside the private tailnet instead of publishing KDE Connect directly to the internet.

Protect the account used for Tailscale with strong authentication and MFA or a passkey.

Also be careful with power commands.

A remote shutdown is still a real shutdown. The 30-second delay gives you a chance to cancel a mistake, but it does not magically protect unsaved files once the countdown reaches zero.

I also avoid adding commands I do not actually use. A smaller remote command list is easier to understand and harder to press accidentally.


20. The part that made this worth keeping#

The feature I care about most is still the original one.

Imagine the laptop is sitting in the office.

I am outside.

My phone is on 5G.

Then the thought appears:

Did I lock the PC?

I open KDE Connect and press:

Lock PC

The command travels through:

Phone
  |
Tailscale
  |
KDE Connect
  |
Windows

Windows locks.

Then the confirmation travels back:

Windows lock event
  |
Task Scheduler
  |
Event-Notify.ps1
  |
Notify-KDE.ps1
  |
KDE Connect
  |
Tailscale
  |
Phone

My phone shows:

PC locked

Done.

No guessing.

No remote desktop session just to look at a lock screen.

No turning around halfway home.

The notification is not telling me that I clicked a button. It is telling me that Windows actually locked.

That tiny distinction is what made the whole project worth keeping.

Final thoughts#

KDE Connect already provides most of the building blocks.

Tailscale removes the same-network limitation.

PowerShell handles the Windows automation.

Task Scheduler adds the event-driven layer.

Put them together and an Android phone becomes a surprisingly capable remote control and lightweight monitoring panel for a Windows PC without exposing the machine directly to the public internet.

What started as:

I forgot to lock my PC.

ended up becoming:

Remote control
+ confirmation
+ monitoring
+ private networking

All because I did not want to spend the drive home wondering whether I pressed Win + L.

Completely reasonable amount of engineering, obviously.

ILKIM.DEV logo

About The Author

Cemil İlkim Teke

Full-stack developer building practical web, mobile, backend, and AI-enabled products.

WORK WITH ME