diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml
index 8ad2acefd..3f0515ea6 100644
--- a/.github/workflows/build_windows_bin.yml
+++ b/.github/workflows/build_windows_bin.yml
@@ -141,3 +141,25 @@ jobs:
overwrite: true
path: |
.pio/build/native-windows/meshtasticd.exe
+
+ - name: Install WiX
+ shell: pwsh
+ run: |
+ dotnet tool install --global wix --version 5.0.2
+ "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
+ & "$env:USERPROFILE\.dotnet\tools\wix.exe" extension add -g WixToolset.Util.wixext/5.0.2
+
+ - name: Build the MSI and winget manifests
+ shell: pwsh
+ run: ./bin/build-winget-package.ps1 -ReleaseTag "v$env:LONG_VERSION" -Validate
+ env:
+ LONG_VERSION: ${{ steps.version.outputs.long }}
+
+ - name: Store the MSI and winget manifests as an artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: meshtasticd-msi-${{ inputs.windows_ver }}-${{ steps.version.outputs.long }}
+ overwrite: true
+ path: |
+ release/winget/*.msi
+ release/winget/manifests/
diff --git a/.github/workflows/package_winget.yml b/.github/workflows/package_winget.yml
new file mode 100644
index 000000000..c8e540194
--- /dev/null
+++ b/.github/workflows/package_winget.yml
@@ -0,0 +1,62 @@
+name: Validate winget manifest
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate-winget:
+ runs-on: windows-2025
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Resolve the newest release and download its MSI
+ id: release
+ shell: pwsh
+ run: |
+ $tag = gh release list --limit 1 --json tagName --jq '.[0].tagName'
+ if (-not $tag) { throw 'No releases found.' }
+ New-Item -ItemType Directory -Force -Path release/download | Out-Null
+ gh release download $tag --pattern '*.msi' --dir release/download
+ $msis = @(Get-ChildItem release/download/*.msi)
+ if ($msis.Count -eq 0) { throw "Release $tag carries no MSI asset." }
+ if ($msis.Count -gt 1) { throw "Release $tag carries $($msis.Count) MSIs; this validates one architecture." }
+ $parsed = [regex]::Match($msis[0].Name, '^meshtasticd-([0-9][0-9A-Za-z.\-]*)-(x64|arm64)\.msi$')
+ if (-not $parsed.Success) { throw "Cannot parse version and architecture out of $($msis[0].Name)." }
+ "tag=$tag", "version=$($parsed.Groups[1].Value)", "arch=$($parsed.Groups[2].Value)", "msi=$($msis[0].FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Generate manifests for the published MSI
+ shell: pwsh
+ run: ./bin/build-winget-package.ps1 -MsiPath $env:MSI_PATH -Version $env:PKG_VERSION -Architecture $env:PKG_ARCH -ReleaseTag $env:RELEASE_TAG -Validate
+ env:
+ MSI_PATH: ${{ steps.release.outputs.msi }}
+ PKG_VERSION: ${{ steps.release.outputs.version }}
+ PKG_ARCH: ${{ steps.release.outputs.arch }}
+ RELEASE_TAG: ${{ steps.release.outputs.tag }}
+
+ - name: Verify the manifest URL resolves to the published asset
+ shell: pwsh
+ run: |
+ $installer = Get-ChildItem release/winget/manifests -Recurse -Filter '*.installer.yaml' | Select-Object -First 1
+ $url = (Select-String -Path $installer.FullName -Pattern 'InstallerUrl:\s*(\S+)').Matches[0].Groups[1].Value
+ $expected = (Select-String -Path $installer.FullName -Pattern 'InstallerSha256:\s*(\S+)').Matches[0].Groups[1].Value
+ Invoke-WebRequest -Uri $url -OutFile fetched.msi
+ $actual = (Get-FileHash fetched.msi -Algorithm SHA256).Hash
+ if ($actual -ne $expected) {
+ throw "InstallerUrl $url hashes to $actual, manifest claims $expected"
+ }
+ "URL and hash agree: $url"
+
+ - name: Upload the manifests as an artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: winget-manifests-${{ steps.release.outputs.version }}
+ overwrite: true
+ path: release/winget/manifests/
diff --git a/.semgrepignore b/.semgrepignore
index fff35f6b7..c42de5345 100644
--- a/.semgrepignore
+++ b/.semgrepignore
@@ -1,4 +1,6 @@
.github/workflows/main_matrix.yml
+.github/workflows/build_windows_bin.yml
+.github/workflows/package_winget.yml
src/mesh/compression/unishox2.cpp
# Emscripten/WebUSB browser glue for the wasm node — not part of the firmware
# binary or its security surface. The format-string rule false-positives on its
diff --git a/bin/build-winget-package.ps1 b/bin/build-winget-package.ps1
new file mode 100644
index 000000000..d8f873b91
--- /dev/null
+++ b/bin/build-winget-package.ps1
@@ -0,0 +1,292 @@
+#!/usr/bin/env pwsh
+#Requires -Version 7.0
+
+<#
+.SYNOPSIS
+ Builds the meshtasticd MSI and the winget manifests that install it.
+
+.DESCRIPTION
+ Writes the MSI and the three winget manifests into -OutputDirectory. The installer manifest
+ pins the MSI's SHA256, so the file must be published unchanged at -InstallerUrl.
+
+ WiX is required, pinned below v6, which refuses to build without accepting the OSMF EULA:
+
+ dotnet tool install --global wix --version 5.0.2
+ wix extension add -g WixToolset.Util.wixext/5.0.2
+
+.EXAMPLE
+ platformio run -e native-windows
+ ./bin/build-winget-package.ps1 -Validate
+
+.EXAMPLE
+ ./bin/build-winget-package.ps1 -Version 2.8.0 -ReleaseTag v2.8.0.1e982fa
+#>
+
+[CmdletBinding()]
+param(
+ [string]$BinaryPath,
+ [string]$SampleConfigPath,
+ # Generate manifests for an MSI that already exists (a published release asset) instead of
+ # building one. WiX is not needed in that mode.
+ [string]$MsiPath,
+ [string]$Version,
+ [string]$ReleaseTag,
+ [string]$OutputDirectory,
+ [string]$PackageIdentifier = 'Meshtastic.Meshtasticd',
+ [ValidateSet('x64', 'arm64')]
+ [string]$Architecture = 'x64',
+ [string]$InstallerUrl,
+ [string]$ManifestVersion = '1.6.0',
+ [switch]$Validate
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$WxsPath = Join-Path $RepoRoot 'packaging/windows/meshtasticd.wxs'
+
+# Same UpgradeCode as the .wxs; also the namespace for the per-version ProductCode below.
+$UpgradeCode = '8f2c6d41-5b3a-4c8e-9f27-1d0a6b4e73c5'
+
+function Get-RepoVersion {
+ param([string]$PropertiesPath)
+
+ $text = Get-Content -LiteralPath $PropertiesPath -Raw
+ $fields = foreach ($name in 'major', 'minor', 'build') {
+ $match = [regex]::Match($text, "(?m)^\s*$name\s*=\s*(\d+)\s*$")
+ if (-not $match.Success) {
+ throw "Could not read '$name' from $PropertiesPath"
+ }
+ $match.Groups[1].Value
+ }
+ $short = $fields -join '.'
+
+ # Mirrors bin/readprops.py: the release tag carries a 7-char SHA, the package version does not.
+ # Empty on a failed or absent git, in which case the tag falls back to the bare version.
+ $sha = ''
+ try {
+ $sha = (git -C $RepoRoot rev-parse --short=7 HEAD 2>$null | Select-Object -First 1)
+ } catch {
+ $sha = ''
+ }
+
+ [pscustomobject]@{
+ Short = $short
+ Long = if ($sha) { "$short.$($sha.Trim())" } else { $short }
+ }
+}
+
+function New-DeterministicGuid {
+ param(
+ [Parameter(Mandatory)][string]$Namespace,
+ [Parameter(Mandatory)][string]$Name
+ )
+
+ # RFC 4122 name-based (v5) UUID. Windows Installer wants a fresh ProductCode per version for
+ # major upgrades to work, and deriving it keeps that reproducible across rebuilds.
+ $bytes = ([guid]$Namespace).ToByteArray()
+ # .NET lays the first three fields out little-endian; RFC 4122 hashes them big-endian.
+ [array]::Reverse($bytes, 0, 4)
+ [array]::Reverse($bytes, 4, 2)
+ [array]::Reverse($bytes, 6, 2)
+
+ $hash = [System.Security.Cryptography.SHA1]::HashData($bytes + [System.Text.Encoding]::UTF8.GetBytes($Name))
+ $guid = $hash[0..15]
+ $guid[6] = ($guid[6] -band 0x0F) -bor 0x50
+ $guid[8] = ($guid[8] -band 0x3F) -bor 0x80
+
+ [array]::Reverse($guid, 0, 4)
+ [array]::Reverse($guid, 4, 2)
+ [array]::Reverse($guid, 6, 2)
+ [guid][byte[]]$guid
+}
+
+function Get-WixCommand {
+ $wix = Get-Command wix -ErrorAction SilentlyContinue
+ if (-not $wix) {
+ $candidate = Join-Path $env:USERPROFILE '.dotnet/tools/wix.exe'
+ if (Test-Path -LiteralPath $candidate) {
+ return $candidate
+ }
+ throw "wix not found. Install it with: dotnet tool install --global wix --version 5.0.2"
+ }
+ $wix.Source
+}
+
+$buildMsi = -not $MsiPath
+if ($buildMsi) {
+ if (-not $BinaryPath) {
+ $BinaryPath = Join-Path $RepoRoot '.pio/build/native-windows/meshtasticd.exe'
+ }
+ if (-not (Test-Path -LiteralPath $BinaryPath)) {
+ throw "meshtasticd.exe not found at '$BinaryPath'. Build it with 'platformio run -e native-windows' or pass -BinaryPath."
+ }
+ $BinaryPath = (Resolve-Path -LiteralPath $BinaryPath).Path
+
+ if (-not $SampleConfigPath) {
+ $SampleConfigPath = Join-Path $RepoRoot 'bin/config-dist.yaml'
+ }
+ if (-not (Test-Path -LiteralPath $SampleConfigPath)) {
+ throw "Sample config not found at '$SampleConfigPath'."
+ }
+ $SampleConfigPath = (Resolve-Path -LiteralPath $SampleConfigPath).Path
+} elseif (-not (Test-Path -LiteralPath $MsiPath)) {
+ throw "MSI not found at '$MsiPath'."
+} else {
+ $MsiPath = (Resolve-Path -LiteralPath $MsiPath).Path
+}
+
+$repoVersion = Get-RepoVersion -PropertiesPath (Join-Path $RepoRoot 'version.properties')
+$versionOverridden = $PSBoundParameters.ContainsKey('Version')
+if (-not $Version) { $Version = $repoVersion.Short }
+if (-not $ReleaseTag) {
+ # An overridden -Version must not keep the checkout's tag: the MSI would not be under it.
+ $ReleaseTag = if ($versionOverridden) { "v$Version" } else { "v$($repoVersion.Long)" }
+}
+if (-not $OutputDirectory) { $OutputDirectory = Join-Path $RepoRoot 'release/winget' }
+
+$msiName = if ($buildMsi) { "meshtasticd-$Version-$Architecture.msi" } else { Split-Path -Leaf $MsiPath }
+if (-not $InstallerUrl) {
+ $InstallerUrl = "https://github.com/meshtastic/firmware/releases/download/$ReleaseTag/$msiName"
+}
+
+$idParts = $PackageIdentifier.Split('.')
+if ($idParts.Count -lt 2) {
+ throw "PackageIdentifier '$PackageIdentifier' must be at least 'Publisher.Package'."
+}
+$manifestSegments = @('manifests', $idParts[0].Substring(0, 1).ToLowerInvariant()) + $idParts + @($Version)
+$manifestDir = Join-Path $OutputDirectory ($manifestSegments -join [IO.Path]::DirectorySeparatorChar)
+
+$null = New-Item -ItemType Directory -Path $OutputDirectory -Force
+$null = New-Item -ItemType Directory -Path $manifestDir -Force
+
+# Derived, not read back from the MSI, so -MsiPath produces the same value the build did.
+$productCode = (New-DeterministicGuid -Namespace $UpgradeCode -Name "$Version-$Architecture").ToString('B').ToUpperInvariant()
+
+if ($buildMsi) {
+ $msiPath = Join-Path $OutputDirectory $msiName
+ $wix = Get-WixCommand
+ & $wix build $WxsPath `
+ -arch $Architecture `
+ -ext WixToolset.Util.wixext `
+ -d Version=$Version `
+ -d ProductCode=$productCode `
+ -d BinaryPath=$BinaryPath `
+ -d ConfigPath=$SampleConfigPath `
+ -o $msiPath
+ if ($LASTEXITCODE -ne 0) {
+ throw "wix build failed with exit code $LASTEXITCODE. If the Util extension is missing: wix extension add -g WixToolset.Util.wixext/5.0.2"
+ }
+} else {
+ $msiPath = $MsiPath
+}
+
+$sha256 = (Get-FileHash -LiteralPath $msiPath -Algorithm SHA256).Hash
+$releaseDate = (Get-Date).ToString('yyyy-MM-dd')
+
+$versionManifest = @"
+# yaml-language-server: `$schema=https://aka.ms/winget-manifest.version.$ManifestVersion.schema.json
+
+PackageIdentifier: $PackageIdentifier
+PackageVersion: $Version
+DefaultLocale: en-US
+ManifestType: version
+ManifestVersion: $ManifestVersion
+"@
+
+$installerManifest = @"
+# yaml-language-server: `$schema=https://aka.ms/winget-manifest.installer.$ManifestVersion.schema.json
+
+PackageIdentifier: $PackageIdentifier
+PackageVersion: $Version
+MinimumOSVersion: 10.0.17763.0
+InstallerType: msi
+Scope: machine
+InstallModes:
+ - interactive
+ - silent
+ - silentWithProgress
+UpgradeBehavior: install
+ReleaseDate: $releaseDate
+Installers:
+ - Architecture: $Architecture
+ InstallerUrl: $InstallerUrl
+ InstallerSha256: $sha256
+ ProductCode: '$productCode'
+ManifestType: installer
+ManifestVersion: $ManifestVersion
+"@
+
+$localeManifest = @"
+# yaml-language-server: `$schema=https://aka.ms/winget-manifest.defaultLocale.$ManifestVersion.schema.json
+
+PackageIdentifier: $PackageIdentifier
+PackageVersion: $Version
+PackageLocale: en-US
+Publisher: Meshtastic
+PublisherUrl: https://meshtastic.org
+PublisherSupportUrl: https://github.com/meshtastic/firmware/issues
+PackageName: meshtasticd
+PackageUrl: https://github.com/meshtastic/firmware
+License: GPL-3.0-only
+LicenseUrl: https://github.com/meshtastic/firmware/blob/master/LICENSE
+ShortDescription: Meshtastic daemon for attached LoRa radio hardware
+Description: |-
+ meshtasticd is the native build of the Meshtastic firmware. It drives a LoRa radio attached
+ over SPI or a CH341 USB bridge and exposes the same TCP API as a Meshtastic device, so any
+ Meshtastic client can connect to it. Installs as an auto-starting Windows service reading
+ %ProgramData%\Meshtastic\config.yaml; without a radio configured there it runs in
+ simulated-radio mode.
+Moniker: meshtasticd
+Tags:
+ - daemon
+ - lora
+ - mesh
+ - meshtastic
+ - radio
+ReleaseNotesUrl: https://github.com/meshtastic/firmware/releases/tag/$ReleaseTag
+Documentations:
+ - DocumentLabel: Linux Native Docs
+ DocumentUrl: https://meshtastic.org/docs/software/linux/
+ManifestType: defaultLocale
+ManifestVersion: $ManifestVersion
+"@
+
+Set-Content -LiteralPath (Join-Path $manifestDir "$PackageIdentifier.yaml") -Value $versionManifest -Encoding utf8NoBOM
+Set-Content -LiteralPath (Join-Path $manifestDir "$PackageIdentifier.installer.yaml") -Value $installerManifest -Encoding utf8NoBOM
+Set-Content -LiteralPath (Join-Path $manifestDir "$PackageIdentifier.locale.en-US.yaml") -Value $localeManifest -Encoding utf8NoBOM
+
+Write-Host "Installer $msiPath"
+Write-Host "SHA256 $sha256"
+Write-Host "ProductCode $productCode"
+Write-Host "Manifests $manifestDir"
+Write-Host "InstallerUrl $InstallerUrl"
+
+if ($Validate) {
+ $winget = Get-Command winget -ErrorAction SilentlyContinue
+ if (-not $winget) {
+ Write-Warning 'winget not found on PATH; skipping manifest validation.'
+ } else {
+ winget validate --manifest $manifestDir
+ if ($LASTEXITCODE -ne 0) {
+ throw "winget validate failed with exit code $LASTEXITCODE."
+ }
+ }
+}
+
+Write-Host ''
+Write-Host "Install locally (elevated): msiexec /i `"$msiPath`" /qn"
+Write-Host "Then check: sc query meshtasticd"
+
+# The banner above goes to the host; callers get the same values on the pipeline.
+[pscustomobject]@{
+ Version = $Version
+ ReleaseTag = $ReleaseTag
+ Architecture = $Architecture
+ MsiPath = $msiPath
+ Sha256 = $sha256
+ ProductCode = $productCode
+ InstallerUrl = $InstallerUrl
+ ManifestDirectory = $manifestDir
+}
diff --git a/packaging/windows/meshtasticd.wxs b/packaging/windows/meshtasticd.wxs
new file mode 100644
index 000000000..6ec71cb27
--- /dev/null
+++ b/packaging/windows/meshtasticd.wxs
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main.cpp b/src/main.cpp
index c737a902f..bdac9c2f8 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -108,6 +108,9 @@ NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr;
#include "mesh/raspihttp/PiWebServer.h"
#endif
#include "platform/portduino/PortduinoGlue.h"
+#ifdef _WIN32
+#include "platform/portduino/windows/WindowsService.h"
+#endif
#include
#include
#include
@@ -1221,6 +1224,11 @@ void setup()
}
}
#endif
+
+#if defined(ARCH_PORTDUINO) && defined(_WIN32)
+ // The node is up; let the SCM stop waiting on START_PENDING. No-op unless --service.
+ windowsServiceReportRunning();
+#endif
}
#endif
diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp
index bc714fcbe..3076be764 100644
--- a/src/platform/portduino/PortduinoGlue.cpp
+++ b/src/platform/portduino/PortduinoGlue.cpp
@@ -43,6 +43,7 @@
// Defined in WindowsMacAddr.cpp, which keeps out of this TU: it
// pulls in RPC/OLE headers that collide with the Arduino API.
bool portduinoWindowsPrimaryMac(uint8_t *dmac);
+#include "windows/WindowsService.h"
#endif
#ifdef __APPLE__
@@ -93,6 +94,9 @@ bool checkConfigPort = true;
// Long-only option: argp treats any key above the printable ASCII range as having no
// single-character equivalent.
#define OPT_CONFIG_CHECK 1001
+#ifdef _WIN32
+#define OPT_SERVICE 1002
+#endif
static error_t parse_opt(int key, char *arg, struct argp_state *state)
{
@@ -123,6 +127,11 @@ static error_t parse_opt(int key, char *arg, struct argp_state *state)
case 'y':
yamlOnly = true;
break;
+#ifdef _WIN32
+ case OPT_SERVICE:
+ windowsServiceInit();
+ break;
+#endif
case ARGP_KEY_ARG:
return 0;
default:
@@ -179,6 +188,9 @@ void portduinoCustomInit()
{"verbose", 'v', 0, 0, "Set log level to full debug"},
{"output-yaml", 'y', 0, 0, "Output config yaml and exit"},
{"check", OPT_CONFIG_CHECK, 0, 0, "Check the configuration for problems, print a report, and exit"},
+#ifdef _WIN32
+ {"service", OPT_SERVICE, 0, 0, "Run as a Windows service"},
+#endif
{0}};
static void *childArguments;
static char doc[] = "Meshtastic native build.";
diff --git a/src/platform/portduino/windows/WindowsService.cpp b/src/platform/portduino/windows/WindowsService.cpp
new file mode 100644
index 000000000..4532ca717
--- /dev/null
+++ b/src/platform/portduino/windows/WindowsService.cpp
@@ -0,0 +1,119 @@
+#include "WindowsService.h"
+
+#if defined(ARCH_PORTDUINO) && defined(_WIN32)
+
+#include "configuration.h"
+#include "main.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+// SERVICE_WIN32_OWN_PROCESS ignores the name, but the SCM still wants a non-null entry.
+static const wchar_t *serviceName = L"meshtasticd";
+
+// Re-reported while setup() runs and again while the shutdown path saves state. The SCM
+// only enforces it against the checkpoint counter, so a generous hint costs nothing.
+static const DWORD PENDING_WAIT_HINT_MS = 15000;
+
+// The SCM calls the control handler on its own thread, so a stop landing during the
+// START_PENDING poll races the startup reports. statusMutex orders them.
+static std::atomic statusHandle{nullptr};
+static std::atomic stopping{false};
+static std::mutex statusMutex;
+static DWORD checkPoint = 1;
+static HANDLE readyEvent = nullptr;
+
+static void reportStatus(DWORD state, DWORD waitHintMs)
+{
+ SERVICE_STATUS_HANDLE handle = statusHandle.load();
+ if (!handle)
+ return;
+
+ std::lock_guard lock(statusMutex);
+ // Latch the stop so a startup report queued behind it cannot walk the state back.
+ if (state == SERVICE_STOP_PENDING || state == SERVICE_STOPPED)
+ stopping.store(true);
+ else if (stopping.load())
+ return;
+
+ SERVICE_STATUS status = {};
+ status.dwServiceType = SERVICE_WIN32_OWN_PROCESS;
+ status.dwCurrentState = state;
+ status.dwControlsAccepted = (state == SERVICE_RUNNING) ? (SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN) : 0;
+ status.dwWin32ExitCode = NO_ERROR;
+ status.dwServiceSpecificExitCode = 0;
+ status.dwWaitHint = waitHintMs;
+ // A stale checkpoint on a settled state makes the SCM think the transition hung.
+ status.dwCheckPoint = (state == SERVICE_RUNNING || state == SERVICE_STOPPED) ? 0 : checkPoint++;
+ SetServiceStatus(handle, &status);
+}
+
+static void reportStopped()
+{
+ reportStatus(SERVICE_STOPPED, 0);
+}
+
+static DWORD WINAPI controlHandler(DWORD control, DWORD, LPVOID, LPVOID)
+{
+ switch (control) {
+ case SERVICE_CONTROL_STOP:
+ case SERVICE_CONTROL_SHUTDOWN:
+ reportStatus(SERVICE_STOP_PENDING, PENDING_WAIT_HINT_MS);
+ // Teardown belongs on the main thread: powerCommandsCheck() saves and exits, and the
+ // atexit hook reports SERVICE_STOPPED on the way out.
+ shutdownAtMsec = millis();
+ return NO_ERROR;
+ case SERVICE_CONTROL_INTERROGATE:
+ return NO_ERROR;
+ default:
+ return ERROR_CALL_NOT_IMPLEMENTED;
+ }
+}
+
+static void WINAPI serviceMain(DWORD, LPWSTR *)
+{
+ statusHandle.store(RegisterServiceCtrlHandlerExW(serviceName, controlHandler, nullptr));
+ if (!statusHandle.load())
+ return;
+
+ // A cold node DB can push setup() past the SCM's 30 s start timeout, so keep the
+ // transition alive with a fresh checkpoint until setup() signals ready.
+ reportStatus(SERVICE_START_PENDING, PENDING_WAIT_HINT_MS);
+ while (!stopping.load() && WaitForSingleObject(readyEvent, 5000) == WAIT_TIMEOUT)
+ reportStatus(SERVICE_START_PENDING, PENDING_WAIT_HINT_MS);
+ reportStatus(SERVICE_RUNNING, 0);
+
+ // Returning would tell the SCM the service stopped while the node is still running.
+ // The process ends from exit() on the main thread instead.
+ Sleep(INFINITE);
+}
+
+void windowsServiceInit()
+{
+ readyEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
+ if (!readyEvent) {
+ LOG_ERROR("Service: CreateEvent failed (%lu), continuing in the foreground", GetLastError());
+ return;
+ }
+ atexit(reportStopped);
+
+ std::thread([] {
+ SERVICE_TABLE_ENTRYW table[] = {{const_cast(serviceName), serviceMain}, {nullptr, nullptr}};
+ // Fails immediately with ERROR_FAILED_SERVICE_CONTROLLER_CONNECT when --service was
+ // typed at a console instead of coming from the SCM. Not fatal: the node runs anyway.
+ if (!StartServiceCtrlDispatcherW(table))
+ LOG_WARN("Service: not started by the SCM (%lu), running in the foreground", GetLastError());
+ }).detach();
+}
+
+void windowsServiceReportRunning()
+{
+ if (readyEvent)
+ SetEvent(readyEvent);
+}
+
+#endif
diff --git a/src/platform/portduino/windows/WindowsService.h b/src/platform/portduino/windows/WindowsService.h
new file mode 100644
index 000000000..c409326c2
--- /dev/null
+++ b/src/platform/portduino/windows/WindowsService.h
@@ -0,0 +1,17 @@
+#pragma once
+
+#if defined(ARCH_PORTDUINO) && defined(_WIN32)
+
+// Connects the process to the Service Control Manager. Called from parse_opt() for --service,
+// which is how the MSI-registered ImagePath starts meshtasticd.
+void windowsServiceInit();
+
+// Moves the SCM status from START_PENDING to RUNNING; called once setup() has returned.
+void windowsServiceReportRunning();
+
+#else
+
+inline void windowsServiceInit() {}
+inline void windowsServiceReportRunning() {}
+
+#endif
diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini
index 76d3fec82..382dff6b0 100644
--- a/variants/native/portduino/platformio.ini
+++ b/variants/native/portduino/platformio.ini
@@ -291,6 +291,7 @@ build_flags = ${portduino_base.build_flags_common}
-lws2_32 ; GpsdSerial.cpp's TCP client
-lbcrypt ; BCryptGenRandom() in HardwareRNG.cpp
-liphlpapi ; GetAdaptersAddresses() host-MAC fallback in PortduinoGlue.cpp
+ -ladvapi32 ; Service Control Manager entry points in windows/WindowsService.cpp
; libch341's libpinedio-usb.h pulls in libusb.h and so , which
; collides with the Arduino API: winuser.h's `typedef struct tagINPUT INPUT` vs
; the PinMode enumerator, and rpcndr.h's `typedef unsigned char boolean` vs