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 04:53:52 +00:00
committed by GitHub
co-authored by Austin
parent 02ea5baad4
commit ecd59e3120
10 files changed
+615

No files matched your search

+12
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.";
@@ -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
@@ -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