From cd01592ff8cf18d7ceca10b2c0b36099fe9f71f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 09:41:01 +0200 Subject: [PATCH] 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. --- .github/workflows/build_windows_bin.yml | 20 ++ .github/workflows/main_matrix.yml | 9 + .github/workflows/package_winget.yml | 60 ++++ bin/build-winget-package.ps1 | 304 ++++++++++++++++++ packaging/windows/meshtasticd.wxs | 86 +++++ src/main.cpp | 8 + src/platform/portduino/PortduinoGlue.cpp | 11 + .../portduino/windows/WindowsService.cpp | 106 ++++++ .../portduino/windows/WindowsService.h | 17 + variants/native/portduino/platformio.ini | 1 + 10 files changed, 622 insertions(+) create mode 100644 .github/workflows/package_winget.yml create mode 100644 bin/build-winget-package.ps1 create mode 100644 packaging/windows/meshtasticd.wxs create mode 100644 src/platform/portduino/windows/WindowsService.cpp create mode 100644 src/platform/portduino/windows/WindowsService.h diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml index 8ad2acefd..bd95809bf 100644 --- a/.github/workflows/build_windows_bin.yml +++ b/.github/workflows/build_windows_bin.yml @@ -141,3 +141,23 @@ 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${{ steps.version.outputs.long }} -Validate + + - 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/main_matrix.yml b/.github/workflows/main_matrix.yml index d7a80fbc6..cb4362e87 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -446,6 +446,7 @@ jobs: - gather-artifacts - build-debian-src - package-pio-deps-native-tft + - Windows # - MacOS steps: - name: Checkout @@ -496,6 +497,13 @@ jobs: merge-multiple: true path: ./output/pio-deps-native-tft + - name: Download the Windows MSI + uses: actions/download-artifact@v8 + with: + pattern: meshtasticd-msi-*-${{ needs.version.outputs.long }} + merge-multiple: true + path: ./output/windows-msi + - name: Zip Linux sources working-directory: output run: | @@ -527,6 +535,7 @@ jobs: gh release upload v${{ needs.version.outputs.long }} ./firmware-${{ needs.version.outputs.long }}.json gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip gh release upload v${{ needs.version.outputs.long }} ./output/platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip + gh release upload v${{ needs.version.outputs.long }} ./output/windows-msi/*.msi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/package_winget.yml b/.github/workflows/package_winget.yml new file mode 100644 index 000000000..a054017e7 --- /dev/null +++ b/.github/workflows/package_winget.yml @@ -0,0 +1,60 @@ +name: Validate winget manifest + +on: + workflow_dispatch: + inputs: + release_tag: + description: Release tag holding the MSI, e.g. v2.8.0.1e982fa + required: true + type: string + version: + description: winget package version, e.g. 2.8.0 + required: true + type: string + +permissions: + contents: read + +jobs: + validate-winget: + runs-on: windows-2025 + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download the MSI from the release + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path release/download | Out-Null + gh release download ${{ inputs.release_tag }} --pattern '*.msi' --dir release/download + Get-ChildItem release/download + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate manifests for the published MSI + shell: pwsh + run: | + $msi = (Get-ChildItem release/download/*.msi | Select-Object -First 1).FullName + ./bin/build-winget-package.ps1 -MsiPath $msi -Version ${{ inputs.version }} -ReleaseTag ${{ inputs.release_tag }} -Validate + + - 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-${{ inputs.version }} + overwrite: true + path: release/winget/manifests/ diff --git a/bin/build-winget-package.ps1 b/bin/build-winget-package.ps1 new file mode 100644 index 000000000..615d991d5 --- /dev/null +++ b/bin/build-winget-package.ps1 @@ -0,0 +1,304 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +<# +.SYNOPSIS + Builds the meshtasticd MSI and the winget manifests that install it. + +.DESCRIPTION + The MSI (packaging/windows/meshtasticd.wxs) drops meshtasticd.exe into Program Files, + registers it as an auto-starting LocalSystem service, and seeds + %ProgramData%\Meshtastic\config.yaml without overwriting an existing one. + + Writes into -OutputDirectory: + + meshtasticd--.msi + manifests/m/Meshtastic/Meshtasticd//Meshtastic.Meshtasticd.yaml + manifests/m/Meshtastic/Meshtasticd//Meshtastic.Meshtasticd.installer.yaml + manifests/m/Meshtastic/Meshtasticd//Meshtastic.Meshtasticd.locale.en-US.yaml + + The installer manifest pins the MSI's SHA256, so the file must be published unchanged at + -InstallerUrl. + + Requires the WiX toolset, pinned below v6: v6 and later refuse to build without accepting + the Open Source Maintenance Fee 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..0673b92e2 --- /dev/null +++ b/packaging/windows/meshtasticd.wxs @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 750396769..28e05f30f 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -42,6 +42,9 @@ // 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" +// Long-only: -s is already --sim, and argp needs a key outside the char range. +#define OPT_SERVICE 0x100 #endif #ifdef __APPLE__ @@ -112,6 +115,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: @@ -166,6 +174,9 @@ void portduinoCustomInit() {"sim", 's', 0, 0, "Run in Simulated radio mode"}, {"verbose", 'v', 0, 0, "Set log level to full debug"}, {"output-yaml", 'y', 0, 0, "Output config yaml 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..6383d8292 --- /dev/null +++ b/src/platform/portduino/windows/WindowsService.cpp @@ -0,0 +1,106 @@ +#include "WindowsService.h" + +#if defined(ARCH_PORTDUINO) && defined(_WIN32) + +#include "configuration.h" +#include "main.h" + +#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; + +static SERVICE_STATUS_HANDLE statusHandle = nullptr; +static SERVICE_STATUS serviceStatus = {}; +static DWORD checkPoint = 1; +static HANDLE readyEvent = nullptr; + +static void reportStatus(DWORD state, DWORD waitHintMs) +{ + if (!statusHandle) + return; + + serviceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + serviceStatus.dwCurrentState = state; + serviceStatus.dwControlsAccepted = (state == SERVICE_RUNNING) ? (SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN) : 0; + serviceStatus.dwWin32ExitCode = NO_ERROR; + serviceStatus.dwServiceSpecificExitCode = 0; + serviceStatus.dwWaitHint = waitHintMs; + // A stale checkpoint on a settled state makes the SCM think the transition hung. + serviceStatus.dwCheckPoint = (state == SERVICE_RUNNING || state == SERVICE_STOPPED) ? 0 : checkPoint++; + SetServiceStatus(statusHandle, &serviceStatus); +} + +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); + // Hand the teardown to the main thread rather than tearing down from this one: + // powerCommandsCheck() runs the usual saveToDisk() and exits, and the atexit hook + // registered below 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 = RegisterServiceCtrlHandlerExW(serviceName, controlHandler, nullptr); + if (!statusHandle) + 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 (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 006a4d0b2..e7071b767 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