Package meshtasticd for Windows as an MSI (#11289)

* Package meshtasticd for Windows as an MSI

Adds a --service flag connecting meshtasticd to the Service Control
Manager, a WiX MSI installing it as an auto-start LocalSystem service with
config in %ProgramData%\Meshtastic, and a CI step attaching the MSI to
releases.

* Address review comments

Bind workflow expressions to env vars in run: bodies, and build the
service status per call with an atomic checkpoint.

* Fix service stop state and CI lint

Latch the stop under a mutex so a startup report cannot walk the state
back. Ignore the new workflows in semgrep and checkov, as main_matrix
already is.

* Drop the checkov ignore for the winget workflow

Resolve the newest release inside the job instead of taking
workflow_dispatch inputs, so CKV_GHA_7 no longer fires and checkov stays
active on the file.

* Carry the MSI architecture into the winget manifest

Parse it from the asset name instead of defaulting to x64, and fail on a
multi-arch release rather than validating one at random.

* Restore release/.gitignore

* Leave the main matrix alone

Release attachment moves to the matrix rework in #11151. The MSI is still
built and uploaded as a CI artifact.

---------

Co-authored-by: Austin <vidplace7@gmail.com>
This commit is contained in:
Thomas Göttgens
2026-07-31 06:53:52 +02:00
committed by GitHub
parent 02ea5baad4
commit ecd59e3120
10 changed files with 615 additions and 0 deletions

View File

@@ -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/

62
.github/workflows/package_winget.yml vendored Normal file
View File

@@ -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/

View File

@@ -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

View File

@@ -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
}

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Built by bin/build-winget-package.ps1, which supplies the preprocessor variables below. -->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
<!-- ProductCode is derived from the version by the build script so the winget manifest can
state it without having to read the finished MSI back. -->
<Package Name="meshtasticd"
Manufacturer="Meshtastic"
Version="$(var.Version)"
ProductCode="$(var.ProductCode)"
UpgradeCode="8f2c6d41-5b3a-4c8e-9f27-1d0a6b4e73c5"
Scope="perMachine"
Compressed="yes">
<SummaryInformation Description="Meshtastic daemon for attached LoRa radio hardware" />
<MajorUpgrade DowngradeErrorMessage="A newer version of meshtasticd is already installed." />
<MediaTemplate EmbedCab="yes" />
<Launch Condition="Installed OR VersionNT64" Message="meshtasticd requires 64-bit Windows." />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="Meshtastic" />
</StandardDirectory>
<!-- A LocalSystem service cannot read a user's Roaming profile, so config and the portduino
virtual filesystem both live in the machine-wide ProgramData tree. -->
<StandardDirectory Id="CommonAppDataFolder">
<Directory Id="DATAFOLDER" Name="Meshtastic" />
</StandardDirectory>
<Feature Id="Main" Title="meshtasticd" Level="1">
<ComponentGroupRef Id="ProductComponents" />
</Feature>
<ComponentGroup Id="ProductComponents">
<Component Directory="INSTALLFOLDER" Guid="3a7e9c02-6f14-4b58-8d93-2c5f0a1e64b7">
<File Id="meshtasticdExe" Source="$(var.BinaryPath)" Name="meshtasticd.exe" KeyPath="yes" />
<!-- -d keeps the portduino VFS out of the LocalSystem profile. Neither path may end in a
backslash: a trailing one would escape the closing quote in the ImagePath. -->
<ServiceInstall Id="MeshtasticdService"
Name="meshtasticd"
DisplayName="Meshtastic Daemon"
Description="Runs a Meshtastic node against attached LoRa hardware and serves the client API."
Type="ownProcess"
Start="auto"
ErrorControl="normal"
Account="LocalSystem"
Vital="no"
Arguments="--service -c &quot;[DATAFOLDER]config.yaml&quot; -d &quot;[DATAFOLDER]data&quot;">
<!-- Covers a radio that is not enumerated yet at boot: the node exits, the SCM retries.
MSI's own ServiceConfig elements are documented as unreliable, hence the util ones. -->
<util:ServiceConfig FirstFailureActionType="restart"
SecondFailureActionType="restart"
ThirdFailureActionType="restart"
RestartServiceDelayInSeconds="30"
ResetPeriodInDays="1" />
</ServiceInstall>
<ServiceControl Id="MeshtasticdServiceControl"
Name="meshtasticd"
Start="install"
Stop="both"
Remove="uninstall"
Wait="yes" />
</Component>
<!-- NeverOverwrite protects an edited config on upgrade and repair; Permanent leaves it (and
the node database beside it) in place on uninstall. -->
<Component Directory="DATAFOLDER"
Guid="b41d8e57-9a20-4f63-b7c1-5e08d2a6394f"
NeverOverwrite="yes"
Permanent="yes">
<File Id="configYaml" Source="$(var.ConfigPath)" Name="config.yaml" KeyPath="yes" />
</Component>
</ComponentGroup>
</Package>
</Wix>

View File

@@ -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 <cstdlib>
#include <fstream>
#include <iostream>
@@ -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

View File

@@ -43,6 +43,7 @@
// Defined in WindowsMacAddr.cpp, which keeps <iphlpapi.h> 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.";

View File

@@ -0,0 +1,119 @@
#include "WindowsService.h"
#if defined(ARCH_PORTDUINO) && defined(_WIN32)
#include "configuration.h"
#include "main.h"
#include <windows.h>
#include <atomic>
#include <cstdlib>
#include <mutex>
#include <thread>
// 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<SERVICE_STATUS_HANDLE> statusHandle{nullptr};
static std::atomic<bool> 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<std::mutex> 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<LPWSTR>(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

View File

@@ -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

View File

@@ -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 <windows.h>, 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