mirror of
https://github.com/meshtastic/firmware.git
synced 2026-08-02 11:29:47 -04:00
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:
22
.github/workflows/build_windows_bin.yml
vendored
22
.github/workflows/build_windows_bin.yml
vendored
@@ -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
62
.github/workflows/package_winget.yml
vendored
Normal 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/
|
||||
@@ -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
|
||||
|
||||
292
bin/build-winget-package.ps1
Normal file
292
bin/build-winget-package.ps1
Normal 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
|
||||
}
|
||||
80
packaging/windows/meshtasticd.wxs
Normal file
80
packaging/windows/meshtasticd.wxs
Normal 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 "[DATAFOLDER]config.yaml" -d "[DATAFOLDER]data"">
|
||||
<!-- 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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.";
|
||||
|
||||
119
src/platform/portduino/windows/WindowsService.cpp
Normal file
119
src/platform/portduino/windows/WindowsService.cpp
Normal 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
|
||||
17
src/platform/portduino/windows/WindowsService.h
Normal file
17
src/platform/portduino/windows/WindowsService.h
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user