From 6ee15076aabeff3a090d7ccf8e9e34af87ce5fb6 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:27:36 -0500 Subject: [PATCH 01/15] Build and run on Windows with clang-cl - CMake: build PortAudio from source on Windows, /W4 for clang-cl, mDNS off, daemon_win.cpp and hooks_null.cpp in place of the fork()-based files - Control channel over Winsock's AF_UNIX through socket_compat.h, shared by the socket and the client; Winsock reports a missing socket file as WSAECONNREFUSED, so the client tells the two apart by whether the path exists - Lock files and the pidfile through LockFileEx() on a byte past any content - The default control socket goes in this user's LocalAppData, found through SHGetKnownFolderPath() and refused unless its ACL leaves it private: owner is this user, and only SYSTEM and Administrators can also write - State in LocalAppData\sendspin-cli; the state file and lock files are created with an ACL granting only this user, the Windows counterpart of mode 0600, and replaced with MoveFileEx(), since std::rename() will not overwrite - getopt_win.h: the getopt_long() subset cli.cpp uses, with BSD semantics - -z and --hook-start/--hook-stop are refused at parse time on Windows Tests, mDNS, hooks and -z on Windows are not done yet. --- CMakeLists.txt | 49 +++++++-- src/cli.cpp | 67 +++++++++++- src/control_client.cpp | 72 ++++++++----- src/control_common.cpp | 24 ++++- src/control_socket.cpp | 113 +++++++++++--------- src/daemon.cpp | 8 +- src/daemon.h | 3 + src/daemon_win.cpp | 133 +++++++++++++++++++++++ src/getopt_win.h | 156 +++++++++++++++++++++++++++ src/hooks.h | 7 ++ src/hooks_null.cpp | 39 +++++++ src/log.cpp | 13 +++ src/main.cpp | 4 + src/mdns_common.cpp | 4 + src/socket_compat.h | 175 +++++++++++++++++++++++++++++++ src/state_store.cpp | 40 ++++++- src/win_security.cpp | 232 +++++++++++++++++++++++++++++++++++++++++ src/win_security.h | 39 +++++++ 18 files changed, 1086 insertions(+), 92 deletions(-) create mode 100644 src/daemon_win.cpp create mode 100644 src/getopt_win.h create mode 100644 src/hooks_null.cpp create mode 100644 src/socket_compat.h create mode 100644 src/win_security.cpp create mode 100644 src/win_security.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2bc5393..8d811ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,7 +103,24 @@ if(SENDSPIN_CLI_WITH_COREAUDIO AND APPLE) list(APPEND SENDSPIN_CLI_BACKENDS coreaudio) endif() -if(SENDSPIN_CLI_WITH_PORTAUDIO) +if(SENDSPIN_CLI_WITH_PORTAUDIO AND WIN32) + # No pkg-config or system PortAudio on Windows, so it is built from source; it is C. + enable_language(C) + FetchContent_Declare( + portaudio + GIT_REPOSITORY https://github.com/PortAudio/portaudio.git + GIT_TAG v19.7.0 + GIT_SHALLOW TRUE + ) + set(PA_BUILD_SHARED OFF CACHE BOOL "Build PortAudio as a shared library") + # v19.7.0 declares cmake_minimum_required(VERSION 3.1), which CMake 4 rejects. + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + FetchContent_MakeAvailable(portaudio) + unset(CMAKE_POLICY_VERSION_MINIMUM) + set(SENDSPIN_CLI_PORTAUDIO_TARGET portaudio_static) + set(SENDSPIN_CLI_PORTAUDIO_ENABLED ON) + list(APPEND SENDSPIN_CLI_BACKENDS portaudio) +elseif(SENDSPIN_CLI_WITH_PORTAUDIO) # No CMake module; IMPORTED_TARGET carries macOS framework links through. find_package(PkgConfig QUIET) if(PkgConfig_FOUND) @@ -111,6 +128,7 @@ if(SENDSPIN_CLI_WITH_PORTAUDIO) endif() if(TARGET PkgConfig::PORTAUDIO) + set(SENDSPIN_CLI_PORTAUDIO_TARGET PkgConfig::PORTAUDIO) set(SENDSPIN_CLI_PORTAUDIO_ENABLED ON) list(APPEND SENDSPIN_CLI_BACKENDS portaudio) elseif(NOT PkgConfig_FOUND) @@ -193,7 +211,11 @@ set(SENDSPIN_CLI_MDNS_ENABLED OFF) set(SENDSPIN_CLI_MDNS_DESCRIPTION "none") if(SENDSPIN_CLI_WITH_MDNS) - if(APPLE) + if(WIN32) + message(STATUS + "sendspin-cli: no mDNS on Windows yet -- the player cannot be discovered and -s " + "mdns: is refused at parse time.") + elseif(APPLE) # dns_sd lives in libSystem here, so there is nothing to find and nothing to link. set(SENDSPIN_CLI_MDNS_ENABLED ON) set(SENDSPIN_CLI_MDNS_DESCRIPTION "dns_sd (Bonjour, built in)") @@ -238,7 +260,12 @@ endif() option(SENDSPIN_CLI_WERROR "Turn warnings from sendspin-cli's own sources into errors" OFF) # Applied per target: FetchContent'd trees (ixwebsocket especially) would fail under -Werror. -set(SENDSPIN_CLI_WARNING_FLAGS -Wall -Wextra -Wpedantic) +if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + # clang-cl reads -Wall as -Weverything; /W4 is its -Wall -Wextra. + set(SENDSPIN_CLI_WARNING_FLAGS /W4 -Wpedantic) +else() + set(SENDSPIN_CLI_WARNING_FLAGS -Wall -Wextra -Wpedantic) +endif() if(SENDSPIN_CLI_WERROR) list(APPEND SENDSPIN_CLI_WARNING_FLAGS -Werror) endif() @@ -254,8 +281,6 @@ add_library(sendspin-cli-core STATIC src/control_common.cpp src/control_socket.cpp src/config_file.cpp - src/daemon.cpp - src/hooks.cpp src/key_value_file.cpp src/log.cpp src/mdns_common.cpp @@ -270,6 +295,18 @@ add_library(sendspin-cli-core STATIC src/supported_formats.cpp ) +# Windows has no fork(), flock() or /bin/sh: no -z, and no hooks yet. +if(WIN32) + target_sources(sendspin-cli-core PRIVATE src/daemon_win.cpp src/hooks_null.cpp src/win_security.cpp) + # POSIX names (open, close, unlink, ...) without the CRT's deprecation warnings. + target_compile_definitions(sendspin-cli-core PUBLIC + NOMINMAX WIN32_LEAN_AND_MEAN NOGDI _CRT_NONSTDC_NO_WARNINGS _CRT_SECURE_NO_WARNINGS) + target_link_libraries(sendspin-cli-core PUBLIC ws2_32 advapi32 shell32 ole32) +else() + target_sources(sendspin-cli-core PRIVATE src/daemon.cpp src/hooks.cpp) + target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_DAEMON SENDSPIN_CLI_HAVE_HOOKS) +endif() + # One MdnsService implementation per build. if(SENDSPIN_CLI_MDNS_ENABLED) target_sources(sendspin-cli-core PRIVATE src/mdns_dnssd.cpp) @@ -319,7 +356,7 @@ if(SENDSPIN_CLI_PORTAUDIO_ENABLED) src/portaudio_sink.cpp src/portaudio_source.cpp ) - target_link_libraries(sendspin-cli-core PUBLIC PkgConfig::PORTAUDIO) + target_link_libraries(sendspin-cli-core PUBLIC ${SENDSPIN_CLI_PORTAUDIO_TARGET}) target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_PORTAUDIO) endif() diff --git a/src/cli.cpp b/src/cli.cpp index 37a83c7..cad1756 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -22,9 +22,18 @@ #include "mdns.h" #include "supported_formats.h" +#ifdef _WIN32 +#include "getopt_win.h" + +#include // getcwd +#include // _MAX_PATH +#include +#define PATH_MAX _MAX_PATH +#else #include #include #include +#endif #include #include @@ -537,7 +546,8 @@ void print_wrapped(std::FILE* out, const char* text, size_t indent) { /// Resets getopt's global state so parse_options() can run more than once in a process. void reset_getopt() { -#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) || \ + defined(_WIN32) optreset = 1; optind = 1; #else @@ -803,6 +813,27 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { } } +#ifndef SENDSPIN_CLI_HAVE_DAEMON + // Refused rather than ignored: the player would quietly stay in the foreground. + if (error.empty() && out.was_given(Opt::Daemonize)) { + fail_for(Opt::Daemonize, + "-z needs fork(), which this platform does not have, so the player cannot " + "detach. Drop -z, and start it from a service manager or a scheduled task."); + } +#endif +#ifndef SENDSPIN_CLI_HAVE_HOOKS + if (error.empty() && out.was_given(Opt::HookStart)) { + fail_for(Opt::HookStart, + "--hook-start is not supported on this platform yet, so the hook would never " + "run. Drop it."); + } + if (error.empty() && out.was_given(Opt::HookStop)) { + fail_for(Opt::HookStop, + "--hook-stop is not supported on this platform yet, so the hook would never " + "run. Drop it."); + } +#endif + // -z sends stdout to /dev/null, so -o stdout under -z is a contradiction. if (error.empty() && out.was_given(Opt::Daemonize) && out.was_given(Opt::Device)) { DeviceSpec spec; @@ -1001,9 +1032,13 @@ void print_usage(std::FILE* out, const char* prog) { DISCOVERY_PREFIX); std::fprintf(out, " does not have)\n"); #endif +#ifdef SENDSPIN_CLI_HAVE_DAEMON std::fprintf(out, " -z Fork into the background and detach from the terminal.\n"); std::fprintf(out, " Refuses -o stdout, whose output would go to /dev/null;\n"); std::fprintf(out, " warns without -f, which is where the log would go\n"); +#else + std::fprintf(out, " -z (needs fork(), which this platform does not have)\n"); +#endif std::fprintf(out, " -P, --pidfile \n"); std::fprintf(out, " Hold as a locked pidfile, refusing to start if\n"); std::fprintf(out, " another instance already holds it. A file left by a\n"); @@ -1016,8 +1051,12 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " ' : ', so filter it with grep\n"); std::fprintf(out, " -f, --logfile \n"); std::fprintf(out, " Write log output to instead of stderr, with a\n"); +#ifdef _WIN32 + std::fprintf(out, " UTC timestamp on every line\n"); +#else std::fprintf(out, " UTC timestamp on every line. SIGHUP reopens the path,\n"); std::fprintf(out, " so logrotate and newsyslog can rotate it\n"); +#endif std::fprintf(out, " --config \n"); std::fprintf(out, " Read this config file instead of searching. Exits 1 if\n"); std::fprintf(out, " it cannot be read, since you named it -- falling back\n"); @@ -1082,10 +1121,11 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " $XDG_RUNTIME_DIR, where that is set. The is\n"); std::fprintf(out, " --port, so two players on one host each get their own --\n"); std::fprintf(out, " and a subcommand needs the same --port or this flag\n"); -#ifdef __APPLE__ - std::fprintf(out, " Where it is not set, as on macOS, this host's own\n"); - std::fprintf(out, " per-user directory is used instead. Never /tmp, which\n"); - std::fprintf(out, " would let any local user drive this player\n"); +#if defined(__APPLE__) || defined(_WIN32) + std::fprintf(out, " Where it is not set, as on macOS and Windows, this\n"); + std::fprintf(out, " host's own per-user directory is used instead. Never a\n"); + std::fprintf(out, " shared one, which would let any local user drive this\n"); + std::fprintf(out, " player\n"); #else std::fprintf(out, " Where it is not set -- a systemd *system* unit has none,\n"); std::fprintf(out, " so pair RuntimeDirectory= with this flag -- there is no\n"); @@ -1098,12 +1138,18 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " Where this player keeps what it remembers across\n"); std::fprintf(out, " restarts -- its identity key and pairings, the last\n"); std::fprintf(out, " server, the output delay and its volume. Defaults to\n"); +#ifdef _WIN32 + std::fprintf(out, " $XDG_STATE_HOME/sendspin-cli, or this user's\n"); + std::fprintf(out, " LocalAppData\\sendspin-cli. Two players on one host\n"); + std::fprintf(out, " need their own, or they share one identity\n"); +#else std::fprintf(out, " $XDG_STATE_HOME/sendspin-cli, or\n"); std::fprintf(out, " $HOME/.local/state/sendspin-cli. A systemd *system*\n"); std::fprintf(out, " unit has neither, so pair StateDirectory= with this\n"); std::fprintf(out, " flag; with none of the three the player still runs and\n"); std::fprintf(out, " simply remembers nothing. Two players on one host need\n"); std::fprintf(out, " their own, or they share one identity\n"); +#endif std::fprintf(out, " --manufacturer \n"); std::fprintf(out, " --product-name \n"); std::fprintf(out, " The device info client/hello carries, shown in server\n"); @@ -1112,6 +1158,9 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " listed as itself\n"); std::fprintf(out, " --hook-start \n"); std::fprintf(out, " --hook-stop \n"); +#ifndef SENDSPIN_CLI_HAVE_HOOKS + std::fprintf(out, " (not supported on this platform yet)\n"); +#endif std::fprintf(out, " Run through /bin/sh when a stream starts or\n"); std::fprintf(out, " stops -- an amplifier relay, a light. The event's facts\n"); std::fprintf(out, " arrive as SENDSPIN_EVENT (start|stop) and, where known,\n"); @@ -1273,11 +1322,19 @@ std::string redact_url_userinfo(const std::string& url) { std::string default_client_name() { char hostname[256]; +#ifdef _WIN32 + // Winsock's gethostname() fails before WSAStartup(), and this runs at parse time. + DWORD size = sizeof(hostname); + if (GetComputerNameExA(ComputerNameDnsHostname, hostname, &size) == 0) { + return FALLBACK_NAME; + } +#else if (gethostname(hostname, sizeof(hostname)) != 0) { return FALLBACK_NAME; } // POSIX does not promise termination when the name does not fit. hostname[sizeof(hostname) - 1] = '\0'; +#endif return hostname[0] == '\0' ? FALLBACK_NAME : hostname; } diff --git a/src/control_client.cpp b/src/control_client.cpp index a935d03..39f26fb 100644 --- a/src/control_client.cpp +++ b/src/control_client.cpp @@ -16,16 +16,17 @@ #include "control.h" -#include -#include -#include +#include "socket_compat.h" -#include #include #include #include #include +#ifdef _WIN32 +#include +#endif + namespace sendspin_cli { namespace { @@ -46,18 +47,23 @@ void split_first_line(const std::string& reply, std::string& first, std::string& } /// Connects to `path`, or explains why not: no socket, nothing accepting, or no permission. -int connect_to_socket(const std::string& path, std::string& error) { +socket_t connect_to_socket(const std::string& path, std::string& error) { // Re-checked because a memcpy into sun_path follows. if (!control_socket_path_fits(path)) { error = "control socket path '" + path + "' does not fit a Unix socket address (" + std::to_string(control_socket_path_limit() - 1) + " bytes)"; - return -1; + return INVALID_SOCKET_HANDLE; + } + + if (!socket_startup()) { + error = "cannot start the socket library: " + socket_error_text(socket_error()); + return INVALID_SOCKET_HANDLE; } - const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) { - error = std::string("cannot create a socket: ") + std::strerror(errno); - return -1; + const socket_t fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == INVALID_SOCKET_HANDLE) { + error = "cannot create a socket: " + socket_error_text(socket_error()); + return INVALID_SOCKET_HANDLE; } sockaddr_un address = {}; @@ -68,51 +74,58 @@ int connect_to_socket(const std::string& path, std::string& error) { return fd; } - const int reason = errno; - ::close(fd); - if (reason == ENOENT) { + const int reason = socket_error(); + close_socket(fd); + ConnectFailure failure = classify_connect_error(reason); +#ifdef _WIN32 + std::error_code ignored; + if (failure == ConnectFailure::Refused && !std::filesystem::exists(path, ignored)) { + failure = ConnectFailure::Missing; + } +#endif + if (failure == ConnectFailure::Missing) { error = "no sendspin-cli is listening on " + path + ". Start one, or point this at the right socket with --control-socket -- and " "note that a non-default --port moves the default path, so the same --port has " "to be given here"; - } else if (reason == ECONNREFUSED) { + } else if (failure == ConnectFailure::Refused) { error = path + " exists but nothing accepted the connection: either the player it belonged to " "is gone and left the file behind, or it already has as many control connections " "open as it will take. Try again, and if it keeps failing, restart the player"; - } else if (reason == EACCES || reason == EPERM) { + } else if (failure == ConnectFailure::Denied) { error = "not allowed to connect to " + path + ": the socket is 0600, so only the user running the player can drive it"; } else { - error = "cannot connect to " + path + ": " + std::strerror(reason); + error = "cannot connect to " + path + ": " + socket_error_text(reason); } - return -1; + return INVALID_SOCKET_HANDLE; } /// Writes the whole of `text`, or says why it could not. -bool write_all(int fd, const std::string& text, std::string& error) { +bool write_all(socket_t fd, const std::string& text, std::string& error) { size_t written = 0; while (written < text.size()) { - const ssize_t wrote = ::write(fd, text.data() + written, text.size() - written); + const long wrote = send_bytes(fd, text.data() + written, text.size() - written); if (wrote > 0) { written += static_cast(wrote); continue; } - if (wrote < 0 && errno == EINTR) { + if (wrote < 0 && socket_interrupted(socket_error())) { continue; } error = "cannot send the request: " + - std::string(wrote < 0 ? std::strerror(errno) : "short write"); + (wrote < 0 ? socket_error_text(socket_error()) : std::string("short write")); return false; } return true; } /// Reads until the daemon closes, which is its end-of-reply marker. -bool read_reply(int fd, std::string& reply, std::string& error) { +bool read_reply(socket_t fd, std::string& reply, std::string& error) { char buffer[4096]; while (true) { - const ssize_t read_bytes = ::read(fd, buffer, sizeof(buffer)); + const long read_bytes = receive_bytes(fd, buffer, sizeof(buffer)); if (read_bytes > 0) { if (reply.size() + static_cast(read_bytes) > MAX_REPLY_BYTES) { error = "the reply is longer than " + std::to_string(MAX_REPLY_BYTES) + @@ -125,10 +138,11 @@ bool read_reply(int fd, std::string& reply, std::string& error) { if (read_bytes == 0) { return true; } - if (errno == EINTR) { + const int reason = socket_error(); + if (socket_interrupted(reason)) { continue; } - error = std::string("cannot read the reply: ") + std::strerror(errno); + error = "cannot read the reply: " + socket_error_text(reason); return false; } } @@ -137,8 +151,10 @@ bool read_reply(int fd, std::string& reply, std::string& error) { ControlStatus run_control_subcommand(const ControlRequest& request, const std::string& path, const std::string& absent_reason, std::FILE* out) { +#ifndef _WIN32 // A daemon closing before our write would raise SIGPIPE; ignore it and report EPIPE. std::signal(SIGPIPE, SIG_IGN); +#endif if (path.empty()) { std::fprintf(stderr, "error: %s\n", @@ -150,8 +166,8 @@ ControlStatus run_control_subcommand(const ControlRequest& request, const std::s } std::string error; - const int fd = connect_to_socket(path, error); - if (fd < 0) { + const socket_t fd = connect_to_socket(path, error); + if (fd == INVALID_SOCKET_HANDLE) { std::fprintf(stderr, "error: %s\n", error.c_str()); return ControlStatus::NoDaemon; } @@ -161,7 +177,7 @@ ControlStatus run_control_subcommand(const ControlRequest& request, const std::s std::string reply; // Read even after a failed write: the daemon's `error` reply beats our guess. const bool received = read_reply(fd, reply, error); - ::close(fd); + close_socket(fd); if (!sent && reply.empty()) { std::fprintf(stderr, "error: %s\n", error.c_str()); diff --git a/src/control_common.cpp b/src/control_common.cpp index bbc0693..1a77d98 100644 --- a/src/control_common.cpp +++ b/src/control_common.cpp @@ -16,10 +16,15 @@ #include "control.h" +#include "socket_compat.h" + +#ifdef _WIN32 +#include "win_security.h" +#else #include #include -#include #include +#endif #include #include @@ -745,6 +750,10 @@ const char* line_state_reason(LineState state) { } bool is_private_runtime_dir(const std::string& path, std::string& reason) { +#ifdef _WIN32 + // No uid or mode bits: the ACL is what keeps other accounts out. + return is_private_directory(path, reason); +#else struct stat info = {}; // stat(), not lstat(): judge a symlink by its target, so a linked runtime dir still works. if (::stat(path.c_str(), &info) != 0) { @@ -765,6 +774,7 @@ bool is_private_runtime_dir(const std::string& path, std::string& reason) { return false; } return true; +#endif } std::string control_platform_runtime_dir(std::string& rejection) { @@ -789,6 +799,12 @@ std::string control_platform_runtime_dir(std::string& rejection) { return {}; } return path; +#elif defined(_WIN32) + const std::string path = local_app_data_dir(); + if (path.empty() || !is_private_runtime_dir(path, rejection)) { + return {}; + } + return path; #else static_cast(rejection); return {}; @@ -832,7 +848,11 @@ std::string control_socket_absent_reason(const ControlRuntimeDir& runtime, runtime.rejection + ". Give --control-socket to choose one, or --no-control to stop asking"; } -#ifdef __APPLE__ +#if defined(_WIN32) + return "$XDG_RUNTIME_DIR is not set and this user's LocalAppData folder could not be " + "found, so there is nowhere user-private to put a control socket. " + "Give --control-socket to choose one, or --no-control to stop asking"; +#elif defined(__APPLE__) return "$XDG_RUNTIME_DIR is not set and this host's own per-user temporary directory " "could not be used, so there is nowhere user-private to put a control socket. " "Give --control-socket to choose one, or --no-control to stop asking"; diff --git a/src/control_socket.cpp b/src/control_socket.cpp index 9ab0eec..e531307 100644 --- a/src/control_socket.cpp +++ b/src/control_socket.cpp @@ -18,13 +18,14 @@ #include "daemon.h" #include "log.h" +#include "socket_compat.h" -#include -#include -#include +#ifdef _WIN32 +#include // close, unlink +#else #include -#include #include +#endif #include #include @@ -40,7 +41,7 @@ static constexpr const char* LOG_TAG = LOG_TAG_CONTROL; namespace { /// Socket mode, forced around bind(): daemonize()'s umask alone would leave it world-connectable. -constexpr mode_t CONTROL_SOCKET_MODE = 0600; +constexpr unsigned CONTROL_SOCKET_MODE = 0600; /// Kernel backlog, equal to the connection cap so a burst hits the cap, which explains itself. constexpr int CONTROL_SOCKET_BACKLOG = static_cast(MAX_CONTROL_CONNECTIONS); @@ -56,7 +57,7 @@ void fill_address(sockaddr_un& address, const std::string& path) { /// One control connection: its descriptor, the line it is assembling, and when it arrived. struct Connection { - int fd{-1}; + socket_t fd{INVALID_SOCKET_HANDLE}; LineAssembler assembler; /// When this connection was accepted, for the idle deadline. int64_t accepted_ms{0}; @@ -65,7 +66,7 @@ struct Connection { } // namespace struct ControlSocket::Impl { - int listener{-1}; + socket_t listener{INVALID_SOCKET_HANDLE}; int lock_fd{-1}; std::string path; std::string lock_path; @@ -90,8 +91,7 @@ ControlSocketStatus probe_control_socket(const std::string& path, std::string& e lock_file(path + CONTROL_LOCK_SUFFIX, "control socket", CONTROL_SOCKET_MODE, fd, error); switch (locked) { case PidFileStatus::Ok: - ::flock(fd, LOCK_UN); - ::close(fd); + release_lock(fd); return ControlSocketStatus::Ok; case PidFileStatus::AlreadyRunning: return ControlSocketStatus::AlreadyRunning; @@ -122,9 +122,15 @@ ControlSocketStatus ControlSocket::open(const std::string& path, std::string& er return ControlSocketStatus::Failed; } - const int listener = ::socket(AF_UNIX, SOCK_STREAM, 0); - if (listener < 0) { - error = std::string("cannot create the control socket: ") + std::strerror(errno); + if (!socket_startup()) { + error = "cannot start the socket library: " + socket_error_text(socket_error()); + ::close(lock_fd); + return ControlSocketStatus::Failed; + } + + const socket_t listener = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (listener == INVALID_SOCKET_HANDLE) { + error = "cannot create the control socket: " + socket_error_text(socket_error()); ::close(lock_fd); return ControlSocketStatus::Failed; } @@ -132,7 +138,7 @@ ControlSocketStatus ControlSocket::open(const std::string& path, std::string& er // Safe under the lock: bind() fails on any existing path, so a stale file must be removed. if (::unlink(path.c_str()) != 0 && errno != ENOENT) { error = "cannot remove the stale control socket " + path + ": " + std::strerror(errno); - ::close(listener); + close_socket(listener); ::close(lock_fd); return ControlSocketStatus::Failed; } @@ -140,34 +146,41 @@ ControlSocketStatus ControlSocket::open(const std::string& path, std::string& er sockaddr_un address = {}; fill_address(address, path); +#ifdef _WIN32 + // No modes: the socket file takes the ACL of its directory, which must be private. + const bool bound = + ::bind(listener, reinterpret_cast(&address), sizeof(address)) == 0; + const int bind_errno = socket_error(); +#else // umask around bind(), so the socket is never world-connectable even briefly. - const mode_t previous_umask = ::umask(0777 & ~CONTROL_SOCKET_MODE); + const mode_t previous_umask = ::umask(0777 & ~static_cast(CONTROL_SOCKET_MODE)); const bool bound = ::bind(listener, reinterpret_cast(&address), sizeof(address)) == 0; const int bind_errno = errno; ::umask(previous_umask); +#endif if (!bound) { - error = "cannot bind the control socket " + path + ": " + std::strerror(bind_errno); - ::close(listener); + error = "cannot bind the control socket " + path + ": " + socket_error_text(bind_errno); + close_socket(listener); ::close(lock_fd); return ControlSocketStatus::Failed; } if (::listen(listener, CONTROL_SOCKET_BACKLOG) != 0) { - error = "cannot listen on the control socket " + path + ": " + std::strerror(errno); + error = "cannot listen on the control socket " + path + ": " + + socket_error_text(socket_error()); ::unlink(path.c_str()); - ::close(listener); + close_socket(listener); ::close(lock_fd); return ControlSocketStatus::Failed; } // Non-blocking: poll() runs on the main loop. - const int flags = ::fcntl(listener, F_GETFL, 0); - if (flags < 0 || ::fcntl(listener, F_SETFL, flags | O_NONBLOCK) != 0) { - error = "cannot make the control socket non-blocking: " + std::string(std::strerror(errno)); + if (!set_nonblocking(listener)) { + error = "cannot make the control socket non-blocking: " + socket_error_text(socket_error()); ::unlink(path.c_str()); - ::close(listener); + close_socket(listener); ::close(lock_fd); return ControlSocketStatus::Failed; } @@ -181,17 +194,18 @@ ControlSocketStatus ControlSocket::open(const std::string& path, std::string& er } void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { - if (this->impl_->listener < 0) { + if (this->impl_->listener == INVALID_SOCKET_HANDLE) { return; } // Drain every waiting connection this tick. while (true) { - const int fd = ::accept(this->impl_->listener, nullptr, nullptr); - if (fd < 0) { - if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + const socket_t fd = ::accept(this->impl_->listener, nullptr, nullptr); + if (fd == INVALID_SOCKET_HANDLE) { + const int reason = socket_error(); + if (!socket_would_block(reason) && !socket_interrupted(reason)) { cli_log(LogLevel::DEBUG, "accept on %s failed: %s", this->impl_->path.c_str(), - std::strerror(errno)); + socket_error_text(reason).c_str()); } break; } @@ -206,16 +220,15 @@ void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { " control connections open, which is as many as it will take -- try again", ""); // Best-effort: the peer is about to be closed anyway. - const ssize_t ignored = ::write(fd, busy.data(), busy.size()); + const long ignored = send_bytes(fd, busy.data(), busy.size()); static_cast(ignored); - ::close(fd); + close_socket(fd); continue; } - const int flags = ::fcntl(fd, F_GETFL, 0); - if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { + if (!set_nonblocking(fd)) { cli_log(LogLevel::DEBUG, "cannot make a control connection non-blocking: %s", - std::strerror(errno)); - ::close(fd); + socket_error_text(socket_error()).c_str()); + close_socket(fd); continue; } Connection connection; @@ -234,7 +247,7 @@ void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { bool closed = false; while (state == LineState::Incomplete && !closed) { - const ssize_t read_bytes = ::read(connection.fd, buffer, sizeof(buffer)); + const long read_bytes = receive_bytes(connection.fd, buffer, sizeof(buffer)); if (read_bytes > 0) { state = connection.assembler.feed(buffer, static_cast(read_bytes)); continue; @@ -245,13 +258,15 @@ void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { state = connection.assembler.finish(); break; } - if (errno == EINTR) { + const int reason = socket_error(); + if (socket_interrupted(reason)) { continue; } - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block(reason)) { break; } - cli_log(LogLevel::DEBUG, "control connection read failed: %s", std::strerror(errno)); + cli_log(LogLevel::DEBUG, "control connection read failed: %s", + socket_error_text(reason).c_str()); closed = true; break; } @@ -262,12 +277,12 @@ void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { } else if (state == LineState::TooLong || state == LineState::Invalid) { reply = encode_control_reply(ControlStatus::Usage, line_state_reason(state), ""); } else if (closed) { - ::close(connection.fd); + close_socket(connection.fd); continue; } else if (now_ms - connection.accepted_ms >= CONTROL_IDLE_TIMEOUT_MS) { cli_log(LogLevel::DEBUG, "dropping a control connection that sent nothing in %lld ms", static_cast(CONTROL_IDLE_TIMEOUT_MS)); - ::close(connection.fd); + close_socket(connection.fd); continue; } else { surviving.push_back(std::move(connection)); @@ -277,21 +292,23 @@ void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { // One command per connection; the reply may need several writes. size_t written = 0; while (written < reply.size()) { - const ssize_t wrote = - ::write(connection.fd, reply.data() + written, reply.size() - written); + const long wrote = + send_bytes(connection.fd, reply.data() + written, reply.size() - written); if (wrote > 0) { written += static_cast(wrote); continue; } - if (wrote < 0 && errno == EINTR) { + const int reason = socket_error(); + if (wrote < 0 && socket_interrupted(reason)) { continue; } // SIGPIPE is ignored process-wide, so a vanished peer shows up here as EPIPE. cli_log(LogLevel::DEBUG, "control reply write failed after %zu of %zu bytes: %s", - written, reply.size(), wrote < 0 ? std::strerror(errno) : "wrote nothing"); + written, reply.size(), + wrote < 0 ? socket_error_text(reason).c_str() : "wrote nothing"); break; } - ::close(connection.fd); + close_socket(connection.fd); } this->impl_->connections = std::move(surviving); @@ -299,13 +316,13 @@ void ControlSocket::poll(int64_t now_ms, ControlHandler& handler) { void ControlSocket::close() { for (Connection& connection : this->impl_->connections) { - ::close(connection.fd); + close_socket(connection.fd); } this->impl_->connections.clear(); - if (this->impl_->listener >= 0) { - ::close(this->impl_->listener); - this->impl_->listener = -1; + if (this->impl_->listener != INVALID_SOCKET_HANDLE) { + close_socket(this->impl_->listener); + this->impl_->listener = INVALID_SOCKET_HANDLE; } if (!this->impl_->path.empty()) { ::unlink(this->impl_->path.c_str()); diff --git a/src/daemon.cpp b/src/daemon.cpp index 2a6866a..7f71eb3 100644 --- a/src/daemon.cpp +++ b/src/daemon.cpp @@ -60,6 +60,11 @@ PidFileStatus lock_file(const std::string& path, const char* what, unsigned mode return PidFileStatus::Failed; } +void release_lock(int fd) { + ::flock(fd, LOCK_UN); + ::close(fd); +} + PidFile::~PidFile() { if (this->fd_ < 0) { return; @@ -104,8 +109,7 @@ PidFileStatus probe_pidfile(const std::string& path, std::string& error) { int fd = -1; const PidFileStatus locked = lock_file(path, "pidfile", PIDFILE_MODE, fd, error); if (locked == PidFileStatus::Ok) { - ::flock(fd, LOCK_UN); - ::close(fd); + release_lock(fd); } return locked; } diff --git a/src/daemon.h b/src/daemon.h index cbc49fd..d9a0793 100644 --- a/src/daemon.h +++ b/src/daemon.h @@ -35,6 +35,9 @@ enum class PidFileStatus { PidFileStatus lock_file(const std::string& path, const char* what, unsigned mode, int& fd, std::string& error); +/// Unlocks and closes a descriptor lock_file() returned. +void release_lock(int fd); + /// Holds the -P pidfile locked for the process's life, and unlinks it on destruction. class PidFile { public: diff --git a/src/daemon_win.cpp b/src/daemon_win.cpp new file mode 100644 index 0000000..f07b8da --- /dev/null +++ b/src/daemon_win.cpp @@ -0,0 +1,133 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// daemon.h on Windows: LockFileEx() in place of flock(), and no fork() for -z. + +#include "daemon.h" + +#include "win_security.h" + +#include +#include +#include + +#include +#include +#include + +namespace sendspin_cli { + +namespace { + +/// One byte far past any content, so the lock never blocks a reader of the pidfile. +OVERLAPPED lock_region() { + OVERLAPPED region = {}; + region.OffsetHigh = 0x7FFFFFFF; + return region; +} + +HANDLE handle_of(int fd) { + return reinterpret_cast(_get_osfhandle(fd)); +} + +} // namespace + +PidFileStatus lock_file(const std::string& path, const char* what, unsigned mode, int& fd, + std::string& error) { + std::string reason; + // Only "no group or other bits" carries over: it becomes an owner-only ACL. + const int opened = open_file(path, false, (mode & 077) == 0, reason); + if (opened < 0) { + error = std::string("cannot open ") + what + " " + path + ": " + reason; + return PidFileStatus::Failed; + } + + OVERLAPPED region = lock_region(); + if (LockFileEx(handle_of(opened), LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, + ®ion) != 0) { + fd = opened; + return PidFileStatus::Ok; + } + + const DWORD code = GetLastError(); + ::close(opened); + if (code == ERROR_LOCK_VIOLATION) { + error = "another sendspin-cli is already running -- it holds the lock on " + path; + return PidFileStatus::AlreadyRunning; + } + SetLastError(code); + error = std::string("cannot lock ") + what + " " + path + ": " + last_error_text(); + return PidFileStatus::Failed; +} + +void release_lock(int fd) { + OVERLAPPED region = lock_region(); + UnlockFileEx(handle_of(fd), 0, 1, 0, ®ion); + ::close(fd); +} + +PidFile::~PidFile() { + if (this->fd_ < 0) { + return; + } + // Unlink while still holding the lock; open_file() shares the file for deletion. + ::unlink(this->path_.c_str()); + release_lock(this->fd_); +} + +PidFileStatus PidFile::acquire(const std::string& path, std::string& error) { + int fd = -1; + const PidFileStatus locked = lock_file(path, "pidfile", 0644, fd, error); + if (locked != PidFileStatus::Ok) { + return locked; + } + + // Truncate only now that the lock is held, or a shorter pid leaves stale digits. + if (_chsize_s(fd, 0) != 0) { + error = "cannot truncate pidfile " + path + ": " + std::strerror(errno); + release_lock(fd); + return PidFileStatus::Failed; + } + + const std::string pid = std::to_string(static_cast(::getpid())) + "\n"; + const int written = ::write(fd, pid.data(), static_cast(pid.size())); + if (written != static_cast(pid.size())) { + error = "cannot write pidfile " + path + ": " + + (written < 0 ? std::strerror(errno) : "short write"); + release_lock(fd); + return PidFileStatus::Failed; + } + + // Set last, so a failure above leaves the destructor nothing to unlink. + this->path_ = path; + this->fd_ = fd; + return PidFileStatus::Ok; +} + +PidFileStatus probe_pidfile(const std::string& path, std::string& error) { + int fd = -1; + const PidFileStatus locked = lock_file(path, "pidfile", 0644, fd, error); + if (locked == PidFileStatus::Ok) { + release_lock(fd); + } + return locked; +} + +bool daemonize(bool /*discard_stderr*/, std::string& error) { + // cli.cpp refuses -z on this platform, so this is only reached by a caller that skipped it. + error = "-z needs fork(), which Windows does not have"; + return false; +} + +} // namespace sendspin_cli diff --git a/src/getopt_win.h b/src/getopt_win.h new file mode 100644 index 0000000..6fe2929 --- /dev/null +++ b/src/getopt_win.h @@ -0,0 +1,156 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// getopt_long() for Windows, which has none: the subset cli.cpp uses, with BSD semantics +/// (no argument permutation, unique long-option prefixes, optreset). + +#pragma once + +#include + +inline char* optarg = nullptr; +inline int optind = 1; +inline int opterr = 1; +inline int optopt = 0; +inline int optreset = 0; + +inline constexpr int no_argument = 0; +inline constexpr int required_argument = 1; + +struct option { + const char* name; + int has_arg; + int* flag; + int val; +}; + +namespace getopt_win { + +/// Position inside a grouped short-option word such as -lz. +inline int short_pos = 1; + +/// The long option `name` (up to `length`) names: exact, else a unique prefix; nullptr otherwise. +inline const option* find_long(const option* longopts, const char* name, size_t length) { + const option* prefix_match = nullptr; + int prefix_matches = 0; + for (const option* opt = longopts; opt->name != nullptr; ++opt) { + if (std::strncmp(opt->name, name, length) != 0) { + continue; + } + if (std::strlen(opt->name) == length) { + return opt; + } + prefix_match = opt; + ++prefix_matches; + } + return prefix_matches == 1 ? prefix_match : nullptr; +} + +/// '?' or ':' for a missing value, as the optstring's leading ':' asks. +inline int missing_value(const char* optstring) { + return optstring[0] == ':' ? ':' : '?'; +} + +} // namespace getopt_win + +inline int getopt_long(int argc, char* const argv[], const char* optstring, const option* longopts, + int* longindex) { + if (optreset != 0 || optind == 0) { + optreset = 0; + optind = 1; + getopt_win::short_pos = 1; + } + optarg = nullptr; + optopt = 0; + + if (getopt_win::short_pos == 1) { + if (optind >= argc || argv[optind][0] != '-' || argv[optind][1] == '\0') { + return -1; + } + if (std::strcmp(argv[optind], "--") == 0) { + ++optind; + return -1; + } + if (argv[optind][1] == '-') { + const char* name = argv[optind] + 2; + const char* equals = std::strchr(name, '='); + const size_t length = + equals != nullptr ? static_cast(equals - name) : std::strlen(name); + const option* opt = getopt_win::find_long(longopts, name, length); + ++optind; + if (opt == nullptr || (opt->has_arg == no_argument && equals != nullptr)) { + return '?'; + } + if (opt->has_arg == required_argument) { + if (equals != nullptr) { + optarg = const_cast(equals + 1); + } else if (optind < argc) { + optarg = argv[optind++]; + } else { + optopt = opt->val; + return getopt_win::missing_value(optstring); + } + } + if (longindex != nullptr) { + *longindex = static_cast(opt - longopts); + } + if (opt->flag != nullptr) { + *opt->flag = opt->val; + return 0; + } + return opt->val; + } + } + + const char* word = argv[optind]; + const char c = word[getopt_win::short_pos]; + const char* spec = c == ':' ? nullptr : std::strchr(optstring, c); + const bool last_in_word = word[getopt_win::short_pos + 1] == '\0'; + const auto next_word = [] { + ++optind; + getopt_win::short_pos = 1; + }; + + if (spec == nullptr) { + optopt = c; + if (last_in_word) { + next_word(); + } else { + ++getopt_win::short_pos; + } + return '?'; + } + if (spec[1] != ':') { + if (last_in_word) { + next_word(); + } else { + ++getopt_win::short_pos; + } + return c; + } + + // Takes a value: the rest of this word, else the next word. + if (!last_in_word) { + optarg = const_cast(word + getopt_win::short_pos + 1); + next_word(); + return c; + } + next_word(); + if (optind < argc) { + optarg = argv[optind++]; + return c; + } + optopt = c; + return getopt_win::missing_value(optstring); +} diff --git a/src/hooks.h b/src/hooks.h index 5928547..ab5df10 100644 --- a/src/hooks.h +++ b/src/hooks.h @@ -16,7 +16,9 @@ #pragma once +#ifndef _WIN32 #include +#endif #include #include @@ -24,6 +26,11 @@ namespace sendspin_cli { +#ifdef _WIN32 +/// hooks_null.cpp spawns nothing; this only keeps RunningHook's shape. +using pid_t = int; +#endif + /// Connection facts exported to a hook; empty fields are left out of the environment. struct HookContext { std::string server_id; ///< SENDSPIN_SERVER_ID: the connected server's id diff --git a/src/hooks_null.cpp b/src/hooks_null.cpp new file mode 100644 index 0000000..f76ea70 --- /dev/null +++ b/src/hooks_null.cpp @@ -0,0 +1,39 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// HookRunner for a platform without hooks; cli.cpp refuses --hook-start and --hook-stop there. + +#include "hooks.h" + +#include "log.h" + +namespace sendspin_cli { + +using sendspin::LogLevel; + +static constexpr const char* LOG_TAG = LOG_TAG_HOOK; + +void HookRunner::run(const std::string& /*command*/, const char* event, + const HookContext& /*context*/) { + cli_log(LogLevel::WARN, "The %s hook was not run: this platform has no hook support", event); +} + +void HookRunner::poll() {} + +void HookRunner::flush() {} + +void HookRunner::spawn(const std::string& /*command*/, const char* /*event*/, + const HookContext& /*context*/) {} + +} // namespace sendspin_cli diff --git a/src/log.cpp b/src/log.cpp index c524dc5..a9b9c25 100644 --- a/src/log.cpp +++ b/src/log.cpp @@ -15,7 +15,14 @@ #include "log.h" #include +#ifdef _WIN32 +#include +// The CRT's names for these. +#define O_CLOEXEC _O_NOINHERIT +#define STDERR_FILENO 2 +#else #include +#endif #include #include @@ -69,9 +76,15 @@ void write_timestamp(char* out, size_t size) { const std::time_t now = std::time(nullptr); std::tm utc{}; // gmtime_r: sinks log from the sync task's thread. +#ifdef _WIN32 + if (::gmtime_s(&utc, &now) != 0) { + return; + } +#else if (::gmtime_r(&now, &utc) == nullptr) { return; } +#endif if (std::strftime(out, size, "%Y-%m-%dT%H:%M:%SZ ", &utc) == 0) { out[0] = '\0'; } diff --git a/src/main.cpp b/src/main.cpp index fa2b88b..2496c1c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -673,10 +673,13 @@ int main(int argc, char* argv[]) { } } +#ifndef _WIN32 // A closed pipe on -o stdout must not kill the daemon. std::signal(SIGPIPE, SIG_IGN); +#endif std::signal(SIGINT, handle_signal); std::signal(SIGTERM, handle_signal); +#ifndef _WIN32 if (!opts.logfile.empty()) { // Only with -f. sigaction() so the handler survives repeated rotations. struct sigaction hup = {}; @@ -685,6 +688,7 @@ int main(int argc, char* argv[]) { hup.sa_flags = SA_RESTART; sigaction(SIGHUP, &hup, nullptr); } +#endif // Above make_audio_sink(), so racing instances collide on the pidfile, not the sound card. PidFile pidfile; diff --git a/src/mdns_common.cpp b/src/mdns_common.cpp index 68a562c..8b6046b 100644 --- a/src/mdns_common.cpp +++ b/src/mdns_common.cpp @@ -16,9 +16,13 @@ #include "mdns.h" +#ifdef _WIN32 +#include +#else #include #include #include +#endif #include #include diff --git a/src/socket_compat.h b/src/socket_compat.h new file mode 100644 index 0000000..f732237 --- /dev/null +++ b/src/socket_compat.h @@ -0,0 +1,175 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// The socket calls the control channel makes, over POSIX sockets or Winsock's AF_UNIX. + +#pragma once + +#ifdef _WIN32 +// winsock2.h must precede afunix.h. +// clang-format off +#include +#include +// clang-format on +#else +#include +#include +#include +#include + +#include +#endif + +#include +#include +#include + +namespace sendspin_cli { + +#ifdef _WIN32 +using socket_t = SOCKET; +inline constexpr socket_t INVALID_SOCKET_HANDLE = INVALID_SOCKET; +#else +using socket_t = int; +inline constexpr socket_t INVALID_SOCKET_HANDLE = -1; +#endif + +/// Starts Winsock once per process. Reference-counted, so it coexists with the library's own. +inline bool socket_startup() { +#ifdef _WIN32 + static const bool STARTED = [] { + WSADATA data; + return WSAStartup(MAKEWORD(2, 2), &data) == 0; + }(); + return STARTED; +#else + return true; +#endif +} + +inline void close_socket(socket_t socket) { +#ifdef _WIN32 + ::closesocket(socket); +#else + ::close(socket); +#endif +} + +/// @return false with socket_error() set when the mode could not be changed. +inline bool set_nonblocking(socket_t socket) { +#ifdef _WIN32 + u_long enabled = 1; + return ::ioctlsocket(socket, FIONBIO, &enabled) == 0; +#else + const int flags = ::fcntl(socket, F_GETFL, 0); + return flags >= 0 && ::fcntl(socket, F_SETFL, flags | O_NONBLOCK) == 0; +#endif +} + +/// Bytes read, 0 at end of stream, negative on error. +inline long receive_bytes(socket_t socket, char* buffer, size_t length) { +#ifdef _WIN32 + return ::recv(socket, buffer, static_cast(length), 0); +#else + return static_cast(::read(socket, buffer, length)); +#endif +} + +/// Bytes written, negative on error. +inline long send_bytes(socket_t socket, const char* data, size_t length) { +#ifdef _WIN32 + return ::send(socket, data, static_cast(length), 0); +#else + return static_cast(::write(socket, data, length)); +#endif +} + +/// The last socket call's error code. +inline int socket_error() { +#ifdef _WIN32 + return ::WSAGetLastError(); +#else + return errno; +#endif +} + +inline bool socket_interrupted(int error) { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +inline bool socket_would_block(int error) { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +inline std::string socket_error_text(int error) { +#ifdef _WIN32 + char text[256] = {}; + const DWORD length = + FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, + static_cast(error), 0, text, sizeof(text), nullptr); + std::string message(text, length); + // FormatMessage ends its text with ".\r\n". + while (!message.empty() && + (message.back() == '\n' || message.back() == '\r' || message.back() == '.')) { + message.pop_back(); + } + return message.empty() ? "error " + std::to_string(error) : message; +#else + return std::strerror(error); +#endif +} + +/// Why connect() failed, as the subcommand explains it. +enum class ConnectFailure { + Missing, ///< no socket file at the path + Refused, ///< a file, but nothing accepting + Denied, ///< not this user's to connect to + Other, +}; + +inline ConnectFailure classify_connect_error(int error) { +#ifdef _WIN32 + // Windows reports a missing AF_UNIX socket file as WSAECONNREFUSED too; see control_client. + switch (error) { + case WSAECONNREFUSED: + return ConnectFailure::Refused; + case WSAEACCES: + return ConnectFailure::Denied; + default: + return ConnectFailure::Other; + } +#else + switch (error) { + case ENOENT: + return ConnectFailure::Missing; + case ECONNREFUSED: + return ConnectFailure::Refused; + case EACCES: + case EPERM: + return ConnectFailure::Denied; + default: + return ConnectFailure::Other; + } +#endif +} + +} // namespace sendspin_cli diff --git a/src/state_store.cpp b/src/state_store.cpp index fae4e99..aaa56d1 100644 --- a/src/state_store.cpp +++ b/src/state_store.cpp @@ -18,9 +18,18 @@ #include "sendspin/config.h" #include "sendspin/persistence_keys.h" +#ifdef _WIN32 +#include "win_security.h" + +#include +#include +#include +#include +#else #include #include #include +#endif #include #include @@ -94,6 +103,11 @@ std::string state_store_path(const std::string& state_dir) { return state_dir + "/" + STATE_FILE; } std::string base = env_or_empty("XDG_STATE_HOME"); +#ifdef _WIN32 + if (base.empty()) { + base = local_app_data_dir(); + } +#endif if (base.empty()) { const std::string home = env_or_empty("HOME"); if (home.empty()) { @@ -264,9 +278,16 @@ bool StateStore::write() const { // Only the leaf directory is created; a mistyped parent should fail visibly. const std::string directory = parent_directory(this->path_); +#ifdef _WIN32 + // Inherits its parent's ACL; the file below is owner-only either way. + if (!directory.empty() && _mkdir(directory.c_str()) != 0 && errno != EEXIST) { + return false; + } +#else if (!directory.empty() && mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST) { return false; } +#endif // Pid in the name, so two players sharing a state dir cannot rename each other's temp file. const std::string temporary = this->path_ + ".tmp." + std::to_string(getpid()); @@ -274,11 +295,17 @@ bool StateStore::write() const { std::remove(temporary.c_str()); // 0600 at creation, so the file is never readable more widely. +#ifdef _WIN32 + std::string ignored; + const int fd = open_file(temporary, true, true, ignored); +#else const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600); +#endif if (fd < 0) { return false; } - std::FILE* file = ::fdopen(fd, "w"); + // "b": no CRLF translation on Windows. + std::FILE* file = ::fdopen(fd, "wb"); if (file == nullptr) { ::close(fd); std::remove(temporary.c_str()); @@ -293,6 +320,16 @@ bool StateStore::write() const { ok = std::fprintf(file, "%s = %s\n", key.c_str(), value.c_str()) > 0; } // fsync before rename, or a power cut can leave an empty file in place. +#ifdef _WIN32 + if (ok) { + ok = std::fflush(file) == 0 && ::_commit(fd) == 0; + } + ok = std::fclose(file) == 0 && ok; + + // std::rename() will not replace an existing file here. + if (!ok || MoveFileExA(temporary.c_str(), this->path_.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) == 0) { +#else if (ok) { ok = std::fflush(file) == 0 && ::fsync(fd) == 0; } @@ -300,6 +337,7 @@ bool StateStore::write() const { ok = std::fclose(file) == 0 && ok; if (!ok || std::rename(temporary.c_str(), this->path_.c_str()) != 0) { +#endif std::remove(temporary.c_str()); return false; } diff --git a/src/win_security.cpp b/src/win_security.cpp new file mode 100644 index 0000000..fd792e3 --- /dev/null +++ b/src/win_security.cpp @@ -0,0 +1,232 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "win_security.h" + +// clang-format off +#include +#include +#include +#include +#include +#include +#include +// clang-format on + +#include +#include + +namespace sendspin_cli { + +namespace { + +/// Rights that let a holder change what is in a directory, or a file created in it. +constexpr ACCESS_MASK WRITE_RIGHTS = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | + FILE_WRITE_ATTRIBUTES | FILE_DELETE_CHILD | DELETE | + WRITE_DAC | WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL; + +struct LocalFreeDeleter { + void operator()(void* memory) const { + LocalFree(memory); + } +}; + +/// The process user's SID, or empty when the token cannot be read. +std::vector current_user_sid() { + HANDLE token = nullptr; + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token) == 0) { + return {}; + } + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector buffer(size); + std::vector sid; + if (size != 0 && GetTokenInformation(token, TokenUser, buffer.data(), size, &size) != 0) { + const PSID user = reinterpret_cast(buffer.data())->User.Sid; + const DWORD length = GetLengthSid(user); + sid.resize(length); + CopySid(length, sid.data(), user); + } + CloseHandle(token); + return sid; +} + +bool is_well_known(PSID sid, WELL_KNOWN_SID_TYPE type) { + return IsWellKnownSid(sid, type) != 0; +} + +/// `DOMAIN\name` for a SID, or its S-1-... form when it has no name. +std::string account_name(PSID sid) { + char name[256]; + char domain[256]; + DWORD name_size = sizeof(name); + DWORD domain_size = sizeof(domain); + SID_NAME_USE use; + if (LookupAccountSidA(nullptr, sid, name, &name_size, domain, &domain_size, &use) != 0) { + return domain_size > 0 ? std::string(domain) + "\\" + name : std::string(name); + } + char* text = nullptr; + if (ConvertSidToStringSidA(sid, &text) == 0) { + return "an unknown account"; + } + std::string result(text); + LocalFree(text); + return result; +} + +} // namespace + +std::string last_error_text() { + const DWORD code = GetLastError(); + char text[256] = {}; + const DWORD length = FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, code, 0, text, sizeof(text), nullptr); + std::string message(text, length); + while (!message.empty() && + (message.back() == '\n' || message.back() == '\r' || message.back() == '.')) { + message.pop_back(); + } + return message.empty() ? "error " + std::to_string(code) : message; +} + +std::string local_app_data_dir() { + // The known folder, not %LOCALAPPDATA%, which the environment could redirect. + PWSTR wide = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DEFAULT, nullptr, &wide))) { + return {}; + } + // The ANSI code page, which every narrow Win32 call here reads paths in. + const int size = WideCharToMultiByte(CP_ACP, 0, wide, -1, nullptr, 0, nullptr, nullptr); + std::string path; + if (size > 1) { + path.resize(static_cast(size)); + WideCharToMultiByte(CP_ACP, 0, wide, -1, path.data(), size, nullptr, nullptr); + path.pop_back(); + } + CoTaskMemFree(wide); + for (char& c : path) { + if (c == '\\') { + c = '/'; + } + } + return path; +} + +bool is_private_directory(const std::string& path, std::string& reason) { + const DWORD attributes = GetFileAttributesA(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) { + reason = "cannot read " + path + ": " + last_error_text(); + return false; + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + reason = path + " is not a directory"; + return false; + } + + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + const DWORD status = GetNamedSecurityInfoA( + path.c_str(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + if (status != ERROR_SUCCESS) { + SetLastError(status); + reason = "cannot read the ACL of " + path + ": " + last_error_text(); + return false; + } + const std::unique_ptr held(descriptor); + + std::vector user = current_user_sid(); + if (user.empty()) { + reason = "cannot tell which user this is: " + last_error_text(); + return false; + } + if (EqualSid(owner, user.data()) == 0) { + reason = path + " is owned by " + account_name(owner) + ", not by this user"; + return false; + } + if (dacl == nullptr) { + reason = path + " has no ACL, which grants everyone full access"; + return false; + } + + for (DWORD index = 0; index < dacl->AceCount; ++index) { + void* entry = nullptr; + if (GetAce(dacl, index, &entry) == 0) { + reason = "cannot read the ACL of " + path + ": " + last_error_text(); + return false; + } + const auto* header = static_cast(entry); + if (header->AceType != ACCESS_ALLOWED_ACE_TYPE) { + continue; + } + const auto* allowed = static_cast(entry); + if ((allowed->Mask & WRITE_RIGHTS) == 0) { + continue; + } + PSID trustee = const_cast(&allowed->SidStart); + // CREATOR OWNER stands for whoever creates the file, which is this user. + if (EqualSid(trustee, user.data()) != 0 || is_well_known(trustee, WinLocalSystemSid) || + is_well_known(trustee, WinBuiltinAdministratorsSid) || + is_well_known(trustee, WinCreatorOwnerSid)) { + continue; + } + reason = path + " grants " + account_name(trustee) + " write access"; + return false; + } + return true; +} + +int open_file(const std::string& path, bool exclusive, bool owner_only, std::string& error) { + SECURITY_ATTRIBUTES attributes = {}; + attributes.nLength = sizeof(attributes); + std::unique_ptr descriptor; + if (owner_only) { + std::vector user = current_user_sid(); + char* sid = nullptr; + if (user.empty() || ConvertSidToStringSidA(user.data(), &sid) == 0) { + error = "cannot tell which user this is: " + last_error_text(); + return -1; + } + // Protected, so nothing inherited from the directory widens it. + const std::string sddl = std::string("D:P(A;;FA;;;") + sid + ")"; + LocalFree(sid); + PSECURITY_DESCRIPTOR built = nullptr; + if (ConvertStringSecurityDescriptorToSecurityDescriptorA(sddl.c_str(), SDDL_REVISION_1, + &built, nullptr) == 0) { + error = "cannot build an owner-only ACL: " + last_error_text(); + return -1; + } + descriptor.reset(built); + attributes.lpSecurityDescriptor = built; + } + + const HANDLE handle = + CreateFileA(path.c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, &attributes, + exclusive ? CREATE_NEW : OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) { + error = last_error_text(); + return -1; + } + const int fd = _open_osfhandle(reinterpret_cast(handle), _O_RDWR | _O_BINARY); + if (fd < 0) { + error = "cannot wrap the file handle"; + CloseHandle(handle); + return -1; + } + return fd; +} + +} // namespace sendspin_cli diff --git a/src/win_security.h b/src/win_security.h new file mode 100644 index 0000000..6098306 --- /dev/null +++ b/src/win_security.h @@ -0,0 +1,39 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Windows stand-ins for POSIX ownership and modes: per-user folders and owner-only ACLs. + +#pragma once + +#include + +namespace sendspin_cli { + +/// This user's LocalAppData folder with '/' separators, or empty when it cannot be found. +std::string local_app_data_dir(); + +/// True if `path` is a directory owned by this user that no one but SYSTEM and Administrators +/// can also write to, or create files in that others could write. +bool is_private_directory(const std::string& path, std::string& reason); + +/// Opens `path` read-write as a CRT descriptor, creating it if missing, like POSIX open(). +/// @param exclusive Fail if it exists, like O_EXCL. +/// @param owner_only A file this creates grants only this user access, like mode 0600. +/// @return -1 with `error` set on failure. +int open_file(const std::string& path, bool exclusive, bool owner_only, std::string& error); + +/// The text for GetLastError()'s current value. +std::string last_error_text(); + +} // namespace sendspin_cli From 43e890785fad438265ea02a05bf2eacee08c5e11 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:31:31 -0500 Subject: [PATCH 02/15] Read the config from roaming AppData on Windows Windows sets neither $XDG_CONFIG_HOME nor $HOME, so the search found nothing. $XDG_CONFIG_HOME is still honoured, then this user's roaming AppData, the counterpart of ~/.config: AppData\Roaming\sendspin-cli\config. There is no system-wide file on Windows yet. Unlike /etc, ProgramData lets any user create files, so a system config there needs an ACL check before anything running as another account could trust it. --- src/config_file.cpp | 12 ++++++++++++ src/win_security.cpp | 19 ++++++++++++++++--- src/win_security.h | 3 +++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/config_file.cpp b/src/config_file.cpp index 31a7bca..73552e2 100644 --- a/src/config_file.cpp +++ b/src/config_file.cpp @@ -16,6 +16,10 @@ #include "key_value_file.h" +#ifdef _WIN32 +#include "win_security.h" +#endif + #include #include #include @@ -48,12 +52,20 @@ std::vector config_search_paths() { if (!xdg.empty()) { paths.push_back(xdg + "/" + CONFIG_SUBDIR + "/" + CONFIG_FILE); } +#ifdef _WIN32 + // No system-wide file: any user may create files under ProgramData, unlike /etc. + const std::string appdata = roaming_app_data_dir(); + if (!appdata.empty()) { + paths.push_back(appdata + "/" + CONFIG_SUBDIR + "/" + CONFIG_FILE); + } +#else const std::string home = env_or_empty("HOME"); if (!home.empty()) { // Listed even when it duplicates the XDG default; the first hit wins anyway. paths.push_back(home + "/.config/" + CONFIG_SUBDIR + "/" + CONFIG_FILE); } paths.push_back(SYSTEM_CONFIG_PATH); +#endif return paths; } diff --git a/src/win_security.cpp b/src/win_security.cpp index fd792e3..c3d9eae 100644 --- a/src/win_security.cpp +++ b/src/win_security.cpp @@ -100,10 +100,13 @@ std::string last_error_text() { return message.empty() ? "error " + std::to_string(code) : message; } -std::string local_app_data_dir() { - // The known folder, not %LOCALAPPDATA%, which the environment could redirect. +namespace { + +/// A known folder with '/' separators, or empty when it cannot be found. +std::string known_folder(REFKNOWNFOLDERID id) { + // The known folder, not its environment variable, which could redirect it. PWSTR wide = nullptr; - if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DEFAULT, nullptr, &wide))) { + if (FAILED(SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, nullptr, &wide))) { return {}; } // The ANSI code page, which every narrow Win32 call here reads paths in. @@ -123,6 +126,16 @@ std::string local_app_data_dir() { return path; } +} // namespace + +std::string local_app_data_dir() { + return known_folder(FOLDERID_LocalAppData); +} + +std::string roaming_app_data_dir() { + return known_folder(FOLDERID_RoamingAppData); +} + bool is_private_directory(const std::string& path, std::string& reason) { const DWORD attributes = GetFileAttributesA(path.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES) { diff --git a/src/win_security.h b/src/win_security.h index 6098306..07a5e2e 100644 --- a/src/win_security.h +++ b/src/win_security.h @@ -23,6 +23,9 @@ namespace sendspin_cli { /// This user's LocalAppData folder with '/' separators, or empty when it cannot be found. std::string local_app_data_dir(); +/// This user's roaming AppData folder, the counterpart of ~/.config; as local_app_data_dir(). +std::string roaming_app_data_dir(); + /// True if `path` is a directory owned by this user that no one but SYSTEM and Administrators /// can also write to, or create files in that others could write. bool is_private_directory(const std::string& path, std::string& reason); From 6e938fbb0cacca60943a562861e2651247dabc91 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:31:32 -0500 Subject: [PATCH 03/15] Shut down cleanly when the console closes, or on logoff or shutdown Ctrl+C reached the SIGINT handler, but closing the console window, logging off or shutting down arrive as console control events, and Windows ends the process as soon as the handler returns. The handler now stops the main loop and holds those events until main() has torn down: the goodbye to the server, the control socket and the pidfile. Windows forces the issue after about 5 s, so it waits at most 4.5 s. --- src/main.cpp | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 2496c1c..7ec91d4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -38,6 +38,10 @@ // For sigaction(), which is not required to declare. #include +#ifdef _WIN32 +#include +#endif + #include #include #include @@ -64,6 +68,36 @@ void handle_signal(int /*sig*/) { g_running.store(false); } +#ifdef _WIN32 +/// Set by TeardownDone once main() has destroyed everything else. +std::atomic g_teardown_done{false}; + +/// Declared first in main(), so it is destroyed last, on every return path. +struct TeardownDone { + TeardownDone() = default; + TeardownDone(const TeardownDone&) = delete; + TeardownDone& operator=(const TeardownDone&) = delete; + ~TeardownDone() { + g_teardown_done.store(true); + } +}; + +/// Window close, logoff and shutdown end the process as soon as this returns, so it holds them +/// until main() has said goodbye, closed the control socket and released the pidfile. +BOOL WINAPI handle_console_event(DWORD event) { + g_running.store(false); + if (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) { + return TRUE; + } + // Windows terminates the process about 5 s after these events regardless. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(4500); + while (!g_teardown_done.load() && std::chrono::steady_clock::now() < deadline) { + Sleep(10); + } + return TRUE; +} +#endif + /// Monotonic milliseconds, immune to wall-clock steps at boot. int64_t monotonic_ms() { return std::chrono::duration_cast( @@ -597,6 +631,9 @@ void start_advertising(MdnsService& mdns, const Options& opts) { } // namespace int main(int argc, char* argv[]) { +#ifdef _WIN32 + const TeardownDone teardown_done; +#endif Options opts; if (!parse_options(argc, argv, opts)) { std::fprintf(stderr, "Try '%s --help' for the full flag list.\n", argv[0]); @@ -679,7 +716,9 @@ int main(int argc, char* argv[]) { #endif std::signal(SIGINT, handle_signal); std::signal(SIGTERM, handle_signal); -#ifndef _WIN32 +#ifdef _WIN32 + SetConsoleCtrlHandler(handle_console_event, TRUE); +#else if (!opts.logfile.empty()) { // Only with -f. sigaction() so the handler survives repeated rotations. struct sigaction hup = {}; From edca1c2863e1eca7aa516ca997a8700c623a59bc Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:35:26 -0500 Subject: [PATCH 04/15] Detach with -z on Windows Windows has no fork(), so daemonize() starts a copy of the player from the command line as typed (GetCommandLineW(), so nothing is re-quoted) and the parent exits 0. A marker in the copy's environment tells its daemonize() it is already detached. The copy starts in the launch directory, so its relative paths absolutize as the parent's did, and only then moves to the system drive's root, as chdir("/") does. The copy gets a hidden console of its own rather than none, so 'taskkill /PID ' without /F delivers the close event main() shuts down cleanly on, as kill does with SIGTERM. absolute_path() also learns Windows paths: a leading '/' test missed drive letters, UNC paths and \-rooted paths. --- CMakeLists.txt | 4 ++-- src/cli.cpp | 26 ++++++++++------------ src/daemon_win.cpp | 55 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d811ea..85618d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -295,7 +295,7 @@ add_library(sendspin-cli-core STATIC src/supported_formats.cpp ) -# Windows has no fork(), flock() or /bin/sh: no -z, and no hooks yet. +# Windows has no fork(), flock() or /bin/sh: its own daemon and lock code, and no hooks yet. if(WIN32) target_sources(sendspin-cli-core PRIVATE src/daemon_win.cpp src/hooks_null.cpp src/win_security.cpp) # POSIX names (open, close, unlink, ...) without the CRT's deprecation warnings. @@ -304,7 +304,7 @@ if(WIN32) target_link_libraries(sendspin-cli-core PUBLIC ws2_32 advapi32 shell32 ole32) else() target_sources(sendspin-cli-core PRIVATE src/daemon.cpp src/hooks.cpp) - target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_DAEMON SENDSPIN_CLI_HAVE_HOOKS) + target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_HOOKS) endif() # One MdnsService implementation per build. diff --git a/src/cli.cpp b/src/cli.cpp index cad1756..eb70f23 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -25,10 +25,9 @@ #ifdef _WIN32 #include "getopt_win.h" -#include // getcwd -#include // _MAX_PATH #include -#define PATH_MAX _MAX_PATH + +#include #else #include #include @@ -510,6 +509,12 @@ bool merge_config(const ConfigFile& config, bool subcommand_run, Options& out, /// `path` made absolute against the cwd, for -z: the daemon chdir()s to /. std::string absolute_path(const std::string& path) { +#ifdef _WIN32 + // Drive letters, UNC paths and \-rooted paths all need more than a leading-'/' test. + std::error_code ignored; + const std::filesystem::path absolute = std::filesystem::absolute(path, ignored); + return absolute.empty() ? path : absolute.string(); +#else if (!path.empty() && path.front() == '/') { return path; } @@ -518,6 +523,7 @@ std::string absolute_path(const std::string& path) { return path; } return std::string(cwd) + "/" + path; +#endif } /// The column --help wraps at. @@ -813,14 +819,6 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { } } -#ifndef SENDSPIN_CLI_HAVE_DAEMON - // Refused rather than ignored: the player would quietly stay in the foreground. - if (error.empty() && out.was_given(Opt::Daemonize)) { - fail_for(Opt::Daemonize, - "-z needs fork(), which this platform does not have, so the player cannot " - "detach. Drop -z, and start it from a service manager or a scheduled task."); - } -#endif #ifndef SENDSPIN_CLI_HAVE_HOOKS if (error.empty() && out.was_given(Opt::HookStart)) { fail_for(Opt::HookStart, @@ -1032,12 +1030,12 @@ void print_usage(std::FILE* out, const char* prog) { DISCOVERY_PREFIX); std::fprintf(out, " does not have)\n"); #endif -#ifdef SENDSPIN_CLI_HAVE_DAEMON std::fprintf(out, " -z Fork into the background and detach from the terminal.\n"); std::fprintf(out, " Refuses -o stdout, whose output would go to /dev/null;\n"); std::fprintf(out, " warns without -f, which is where the log would go\n"); -#else - std::fprintf(out, " -z (needs fork(), which this platform does not have)\n"); +#ifdef _WIN32 + std::fprintf(out, " On Windows it starts a hidden copy of itself, which\n"); + std::fprintf(out, " 'taskkill /PID ' (no /F) stops cleanly\n"); #endif std::fprintf(out, " -P, --pidfile \n"); std::fprintf(out, " Hold as a locked pidfile, refusing to start if\n"); diff --git a/src/daemon_win.cpp b/src/daemon_win.cpp index f07b8da..7244a66 100644 --- a/src/daemon_win.cpp +++ b/src/daemon_win.cpp @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// daemon.h on Windows: LockFileEx() in place of flock(), and no fork() for -z. +/// daemon.h on Windows: LockFileEx() in place of flock(), and a detached copy in place of fork(). #include "daemon.h" @@ -30,6 +30,9 @@ namespace sendspin_cli { namespace { +/// Set in the environment of the copy daemonize() starts. +constexpr const wchar_t* DETACHED_MARKER = L"SENDSPIN_CLI_DETACHED"; + /// One byte far past any content, so the lock never blocks a reader of the pidfile. OVERLAPPED lock_region() { OVERLAPPED region = {}; @@ -125,9 +128,53 @@ PidFileStatus probe_pidfile(const std::string& path, std::string& error) { } bool daemonize(bool /*discard_stderr*/, std::string& error) { - // cli.cpp refuses -z on this platform, so this is only reached by a caller that skipped it. - error = "-z needs fork(), which Windows does not have"; - return false; + // The system drive's root, as chdir("/") does: nothing keeps the launch directory busy. + wchar_t root[MAX_PATH] = L"C:\\"; + if (GetSystemWindowsDirectoryW(root, MAX_PATH) >= 3) { + root[3] = L'\0'; + } + + // The copy started below re-runs main() from the same command line; the marker says so. + // It starts in the launch directory, so its relative paths absolutize as the parent's did. + if (GetEnvironmentVariableW(DETACHED_MARKER, nullptr, 0) != 0) { + SetEnvironmentVariableW(DETACHED_MARKER, nullptr); + if (SetCurrentDirectoryW(root) == 0) { + error = "cannot change directory to the drive root: " + last_error_text(); + return false; + } + return true; + } + + wchar_t module[MAX_PATH]; + const DWORD module_length = GetModuleFileNameW(nullptr, module, MAX_PATH); + if (module_length == 0 || module_length == MAX_PATH) { + error = "cannot find this executable to start it detached: " + last_error_text(); + return false; + } + // The command line as typed, so nothing is re-quoted; CreateProcessW() may write to it. + std::wstring command_line = GetCommandLineW(); + + // A hidden console of its own: no terminal, yet taskkill /PID still delivers the close event + // main() shuts down cleanly on, as kill does with SIGTERM. + STARTUPINFOW startup = {}; + startup.cb = sizeof(startup); + startup.dwFlags = STARTF_USESHOWWINDOW; + startup.wShowWindow = SW_HIDE; + PROCESS_INFORMATION process = {}; + SetEnvironmentVariableW(DETACHED_MARKER, L"1"); + const BOOL created = CreateProcessW(module, command_line.data(), nullptr, nullptr, FALSE, + CREATE_NEW_CONSOLE, nullptr, nullptr, &startup, &process); + const DWORD reason = GetLastError(); + SetEnvironmentVariableW(DETACHED_MARKER, nullptr); + if (created == 0) { + SetLastError(reason); + error = "cannot start the detached player: " + last_error_text(); + return false; + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + // _exit(), as on POSIX: the copy owns everything from here. + _exit(0); } } // namespace sendspin_cli From eabb35f17917b38e374955069bda7a0591f90998 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:39:33 -0500 Subject: [PATCH 05/15] Run hooks on Windows hooks.cpp keeps HookRunner's queueing for every platform; starting a hook and reaping it move to hooks_posix.cpp (fork(), execve() and waitpid(), unchanged) and hooks_win.cpp. On Windows a hook runs as cmd.exe /d /s /c through CreateProcessW(), without a window. Its environment is ours minus inherited SENDSPIN_* variables plus the event's own, as on POSIX. stdout goes to stderr and stdin reads NUL, and only those three handles are inherited (PROC_THREAD_ATTRIBUTE_HANDLE_LIST), the counterpart of closing every other descriptor in the child, so a hook cannot hold the player's sockets across a restart. A non-zero exit is a warning. --- CMakeLists.txt | 8 +- src/cli.cpp | 20 +---- src/hooks.cpp | 125 +------------------------- src/hooks.h | 15 +++- src/hooks_null.cpp | 39 --------- src/hooks_posix.cpp | 153 ++++++++++++++++++++++++++++++++ src/hooks_win.cpp | 208 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 384 insertions(+), 184 deletions(-) delete mode 100644 src/hooks_null.cpp create mode 100644 src/hooks_posix.cpp create mode 100644 src/hooks_win.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 85618d2..3e0753d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -281,6 +281,7 @@ add_library(sendspin-cli-core STATIC src/control_common.cpp src/control_socket.cpp src/config_file.cpp + src/hooks.cpp src/key_value_file.cpp src/log.cpp src/mdns_common.cpp @@ -295,16 +296,15 @@ add_library(sendspin-cli-core STATIC src/supported_formats.cpp ) -# Windows has no fork(), flock() or /bin/sh: its own daemon and lock code, and no hooks yet. +# Windows has no fork(), flock() or /bin/sh, so -z, locks and hooks have their own implementation. if(WIN32) - target_sources(sendspin-cli-core PRIVATE src/daemon_win.cpp src/hooks_null.cpp src/win_security.cpp) + target_sources(sendspin-cli-core PRIVATE src/daemon_win.cpp src/hooks_win.cpp src/win_security.cpp) # POSIX names (open, close, unlink, ...) without the CRT's deprecation warnings. target_compile_definitions(sendspin-cli-core PUBLIC NOMINMAX WIN32_LEAN_AND_MEAN NOGDI _CRT_NONSTDC_NO_WARNINGS _CRT_SECURE_NO_WARNINGS) target_link_libraries(sendspin-cli-core PUBLIC ws2_32 advapi32 shell32 ole32) else() - target_sources(sendspin-cli-core PRIVATE src/daemon.cpp src/hooks.cpp) - target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_HOOKS) + target_sources(sendspin-cli-core PRIVATE src/daemon.cpp src/hooks_posix.cpp) endif() # One MdnsService implementation per build. diff --git a/src/cli.cpp b/src/cli.cpp index eb70f23..23caeed 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -819,19 +819,6 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { } } -#ifndef SENDSPIN_CLI_HAVE_HOOKS - if (error.empty() && out.was_given(Opt::HookStart)) { - fail_for(Opt::HookStart, - "--hook-start is not supported on this platform yet, so the hook would never " - "run. Drop it."); - } - if (error.empty() && out.was_given(Opt::HookStop)) { - fail_for(Opt::HookStop, - "--hook-stop is not supported on this platform yet, so the hook would never " - "run. Drop it."); - } -#endif - // -z sends stdout to /dev/null, so -o stdout under -z is a contradiction. if (error.empty() && out.was_given(Opt::Daemonize) && out.was_given(Opt::Device)) { DeviceSpec spec; @@ -1156,10 +1143,11 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " listed as itself\n"); std::fprintf(out, " --hook-start \n"); std::fprintf(out, " --hook-stop \n"); -#ifndef SENDSPIN_CLI_HAVE_HOOKS - std::fprintf(out, " (not supported on this platform yet)\n"); -#endif +#ifdef _WIN32 + std::fprintf(out, " Run through cmd.exe when a stream starts or\n"); +#else std::fprintf(out, " Run through /bin/sh when a stream starts or\n"); +#endif std::fprintf(out, " stops -- an amplifier relay, a light. The event's facts\n"); std::fprintf(out, " arrive as SENDSPIN_EVENT (start|stop) and, where known,\n"); std::fprintf(out, " SENDSPIN_SERVER_ID, SENDSPIN_SERVER_NAME,\n"); diff --git a/src/hooks.cpp b/src/hooks.cpp index eb644f3..d14a80f 100644 --- a/src/hooks.cpp +++ b/src/hooks.cpp @@ -12,21 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// HookRunner's queueing; starting and reaping a hook live in hooks_posix.cpp and hooks_win.cpp. + #include "hooks.h" #include "log.h" -#include -#include -#include - -#include -#include -#include -#include - -// The environ POSIX promises but no header is required to declare. -extern char** environ; +#include namespace sendspin_cli { @@ -34,51 +26,6 @@ using sendspin::LogLevel; static constexpr const char* LOG_TAG = LOG_TAG_HOOK; -namespace { - -/// Prefix of every hook variable; inherited ones are cleared first. -constexpr const char* ENV_PREFIX = "SENDSPIN_"; - -/// Cap on the child's close loop, for an unlimited or huge descriptor limit. -constexpr rlim_t FD_CLOSE_CAP = 4096; - -/// Where the child's close loop stops; call before the fork. -int fd_close_limit() { - rlimit limit{}; - if (getrlimit(RLIMIT_NOFILE, &limit) != 0) { - return static_cast(FD_CLOSE_CAP); - } - return static_cast(std::min(limit.rlim_cur, FD_CLOSE_CAP)); -} - -/// Appends `name=value` to `env`, or nothing when the value is empty. -void add_env(std::vector& env, const char* name, const std::string& value) { - if (value.empty()) { - return; - } - env.push_back(std::string(name) + "=" + value); -} - -/// Our environment minus inherited SENDSPIN_* variables, plus the event's own. -std::vector hook_environment(const char* event, const HookContext& context) { - std::vector env; - for (char** entry = environ; *entry != nullptr; ++entry) { - if (std::strncmp(*entry, ENV_PREFIX, std::strlen(ENV_PREFIX)) == 0) { - continue; - } - env.emplace_back(*entry); - } - add_env(env, "SENDSPIN_EVENT", event); - add_env(env, "SENDSPIN_SERVER_ID", context.server_id); - add_env(env, "SENDSPIN_SERVER_NAME", context.server_name); - add_env(env, "SENDSPIN_SERVER_URL", context.server_url); - add_env(env, "SENDSPIN_CLIENT_ID", context.client_id); - add_env(env, "SENDSPIN_CLIENT_NAME", context.client_name); - return env; -} - -} // namespace - void HookRunner::run(const std::string& command, const char* event, const HookContext& context) { if (!this->running_.empty()) { if (this->pending_.has_value()) { @@ -93,33 +40,10 @@ void HookRunner::run(const std::string& command, const char* event, const HookCo void HookRunner::poll() { for (size_t index = 0; index < this->running_.size();) { - const RunningHook& hook = this->running_[index]; - int status = 0; - const pid_t reaped = waitpid(hook.pid, &status, WNOHANG); - if (reaped == 0) { + if (!this->reap(this->running_[index])) { ++index; continue; } - if (reaped < 0 && errno == EINTR) { - // Interrupted: the hook is still running, so ask again next poll(). - ++index; - continue; - } - if (reaped < 0) { - // ECHILD: something else reaped it, so drop the entry. - cli_log(LogLevel::DEBUG, "The %s hook [%d] could not be waited on (%s)", - hook.event.c_str(), static_cast(hook.pid), std::strerror(errno)); - } - if (reaped == hook.pid && WIFEXITED(status) && WEXITSTATUS(status) != 0) { - cli_log(LogLevel::WARN, "The %s hook [%d] exited %d", hook.event.c_str(), - static_cast(hook.pid), WEXITSTATUS(status)); - } else if (reaped == hook.pid && WIFSIGNALED(status)) { - cli_log(LogLevel::WARN, "The %s hook [%d] was killed by signal %d", hook.event.c_str(), - static_cast(hook.pid), WTERMSIG(status)); - } else if (reaped == hook.pid) { - cli_log(LogLevel::DEBUG, "The %s hook [%d] finished", hook.event.c_str(), - static_cast(hook.pid)); - } this->running_.erase(this->running_.begin() + static_cast(index)); } @@ -143,45 +67,4 @@ void HookRunner::flush() { this->spawn(next.command, next.event.c_str(), next.context); } -void HookRunner::spawn(const std::string& command, const char* event, const HookContext& context) { - // Built before the fork: the child may only make async-signal-safe calls. - const std::vector env = hook_environment(event, context); - std::vector envp; - envp.reserve(env.size() + 1); - for (const std::string& entry : env) { - envp.push_back(const_cast(entry.c_str())); - } - envp.push_back(nullptr); - - char* const argv[] = {const_cast("sh"), const_cast("-c"), - const_cast(command.c_str()), nullptr}; - - const int fd_limit = fd_close_limit(); - - const pid_t pid = fork(); - if (pid < 0) { - cli_log(LogLevel::WARN, "Could not run the %s hook: fork: %s", event, std::strerror(errno)); - return; - } - if (pid == 0) { - // Async-signal-safe calls only from here. stdout may carry PCM, so point it at stderr. - if (dup2(STDERR_FILENO, STDOUT_FILENO) < 0) { - _exit(127); - } - // Close the player's descriptors so a hook cannot hold its ports across a restart. - for (int fd = STDERR_FILENO + 1; fd < fd_limit; ++fd) { - close(fd); - } - // An ignored SIGPIPE survives execve(); give the hook the default. - std::signal(SIGPIPE, SIG_DFL); - execve("/bin/sh", argv, envp.data()); - // exec failed; 127 is the shell's "command not found". - _exit(127); - } - - // Never log the command: it may carry credentials. - cli_log(LogLevel::DEBUG, "Running %s hook [%d]", event, static_cast(pid)); - this->running_.push_back({pid, event}); -} - } // namespace sendspin_cli diff --git a/src/hooks.h b/src/hooks.h index ab5df10..12ba0c9 100644 --- a/src/hooks.h +++ b/src/hooks.h @@ -27,8 +27,8 @@ namespace sendspin_cli { #ifdef _WIN32 -/// hooks_null.cpp spawns nothing; this only keeps RunningHook's shape. -using pid_t = int; +/// A process id, a DWORD. +using pid_t = unsigned long; #endif /// Connection facts exported to a hook; empty fields are left out of the environment. @@ -41,7 +41,8 @@ struct HookContext { std::string client_name; ///< SENDSPIN_CLIENT_NAME: this player's friendly name }; -/// Runs hooks as `/bin/sh -c`, one at a time in event order, without blocking the caller. +/// Runs hooks as `/bin/sh -c` (`cmd.exe /c` on Windows), one at a time in event order, without +/// blocking the caller. /// While one runs, only the newest event waits. Every method must be called on the main loop. class HookRunner { public: @@ -65,6 +66,9 @@ class HookRunner { struct RunningHook { pid_t pid; std::string event; +#ifdef _WIN32 + void* process; ///< the HANDLE reap() waits on and closes +#endif }; /// The event waiting for the running hook to finish, newest wins. @@ -74,9 +78,12 @@ class HookRunner { HookContext context; }; - /// Forks and execs one hook unconditionally. + /// Starts one hook unconditionally. void spawn(const std::string& command, const char* event, const HookContext& context); + /// Logs how a finished hook ended. @return false while it is still running. + bool reap(RunningHook& hook); + std::vector running_; std::optional pending_; }; diff --git a/src/hooks_null.cpp b/src/hooks_null.cpp deleted file mode 100644 index f76ea70..0000000 --- a/src/hooks_null.cpp +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2026 sendspin-cpp-cli Contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/// HookRunner for a platform without hooks; cli.cpp refuses --hook-start and --hook-stop there. - -#include "hooks.h" - -#include "log.h" - -namespace sendspin_cli { - -using sendspin::LogLevel; - -static constexpr const char* LOG_TAG = LOG_TAG_HOOK; - -void HookRunner::run(const std::string& /*command*/, const char* event, - const HookContext& /*context*/) { - cli_log(LogLevel::WARN, "The %s hook was not run: this platform has no hook support", event); -} - -void HookRunner::poll() {} - -void HookRunner::flush() {} - -void HookRunner::spawn(const std::string& /*command*/, const char* /*event*/, - const HookContext& /*context*/) {} - -} // namespace sendspin_cli diff --git a/src/hooks_posix.cpp b/src/hooks_posix.cpp new file mode 100644 index 0000000..dfa5cea --- /dev/null +++ b/src/hooks_posix.cpp @@ -0,0 +1,153 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// HookRunner's POSIX half: fork() and execve() of /bin/sh -c, reaped with waitpid(). + +#include "hooks.h" + +#include "log.h" + +#include +#include +#include + +#include +#include +#include +#include + +// The environ POSIX promises but no header is required to declare. +extern char** environ; + +namespace sendspin_cli { + +using sendspin::LogLevel; + +static constexpr const char* LOG_TAG = LOG_TAG_HOOK; + +namespace { + +/// Prefix of every hook variable; inherited ones are cleared first. +constexpr const char* ENV_PREFIX = "SENDSPIN_"; + +/// Cap on the child's close loop, for an unlimited or huge descriptor limit. +constexpr rlim_t FD_CLOSE_CAP = 4096; + +/// Where the child's close loop stops; call before the fork. +int fd_close_limit() { + rlimit limit{}; + if (getrlimit(RLIMIT_NOFILE, &limit) != 0) { + return static_cast(FD_CLOSE_CAP); + } + return static_cast(std::min(limit.rlim_cur, FD_CLOSE_CAP)); +} + +/// Appends `name=value` to `env`, or nothing when the value is empty. +void add_env(std::vector& env, const char* name, const std::string& value) { + if (value.empty()) { + return; + } + env.push_back(std::string(name) + "=" + value); +} + +/// Our environment minus inherited SENDSPIN_* variables, plus the event's own. +std::vector hook_environment(const char* event, const HookContext& context) { + std::vector env; + for (char** entry = environ; *entry != nullptr; ++entry) { + if (std::strncmp(*entry, ENV_PREFIX, std::strlen(ENV_PREFIX)) == 0) { + continue; + } + env.emplace_back(*entry); + } + add_env(env, "SENDSPIN_EVENT", event); + add_env(env, "SENDSPIN_SERVER_ID", context.server_id); + add_env(env, "SENDSPIN_SERVER_NAME", context.server_name); + add_env(env, "SENDSPIN_SERVER_URL", context.server_url); + add_env(env, "SENDSPIN_CLIENT_ID", context.client_id); + add_env(env, "SENDSPIN_CLIENT_NAME", context.client_name); + return env; +} + +} // namespace + +bool HookRunner::reap(RunningHook& hook) { + int status = 0; + const pid_t reaped = waitpid(hook.pid, &status, WNOHANG); + if (reaped == 0) { + return false; + } + if (reaped < 0 && errno == EINTR) { + // Interrupted: the hook is still running, so ask again next poll(). + return false; + } + if (reaped < 0) { + // ECHILD: something else reaped it, so drop the entry. + cli_log(LogLevel::DEBUG, "The %s hook [%d] could not be waited on (%s)", hook.event.c_str(), + static_cast(hook.pid), std::strerror(errno)); + } + if (reaped == hook.pid && WIFEXITED(status) && WEXITSTATUS(status) != 0) { + cli_log(LogLevel::WARN, "The %s hook [%d] exited %d", hook.event.c_str(), + static_cast(hook.pid), WEXITSTATUS(status)); + } else if (reaped == hook.pid && WIFSIGNALED(status)) { + cli_log(LogLevel::WARN, "The %s hook [%d] was killed by signal %d", hook.event.c_str(), + static_cast(hook.pid), WTERMSIG(status)); + } else if (reaped == hook.pid) { + cli_log(LogLevel::DEBUG, "The %s hook [%d] finished", hook.event.c_str(), + static_cast(hook.pid)); + } + return true; +} + +void HookRunner::spawn(const std::string& command, const char* event, const HookContext& context) { + // Built before the fork: the child may only make async-signal-safe calls. + const std::vector env = hook_environment(event, context); + std::vector envp; + envp.reserve(env.size() + 1); + for (const std::string& entry : env) { + envp.push_back(const_cast(entry.c_str())); + } + envp.push_back(nullptr); + + char* const argv[] = {const_cast("sh"), const_cast("-c"), + const_cast(command.c_str()), nullptr}; + + const int fd_limit = fd_close_limit(); + + const pid_t pid = fork(); + if (pid < 0) { + cli_log(LogLevel::WARN, "Could not run the %s hook: fork: %s", event, std::strerror(errno)); + return; + } + if (pid == 0) { + // Async-signal-safe calls only from here. stdout may carry PCM, so point it at stderr. + if (dup2(STDERR_FILENO, STDOUT_FILENO) < 0) { + _exit(127); + } + // Close the player's descriptors so a hook cannot hold its ports across a restart. + for (int fd = STDERR_FILENO + 1; fd < fd_limit; ++fd) { + close(fd); + } + // An ignored SIGPIPE survives execve(); give the hook the default. + std::signal(SIGPIPE, SIG_DFL); + execve("/bin/sh", argv, envp.data()); + // exec failed; 127 is the shell's "command not found". + _exit(127); + } + + // Never log the command: it may carry credentials. + cli_log(LogLevel::DEBUG, "Running %s hook [%d]", event, static_cast(pid)); + this->running_.push_back({pid, event}); +} + +} // namespace sendspin_cli diff --git a/src/hooks_win.cpp b/src/hooks_win.cpp new file mode 100644 index 0000000..435dd92 --- /dev/null +++ b/src/hooks_win.cpp @@ -0,0 +1,208 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// HookRunner's Windows half: CreateProcessW() of cmd.exe /c, reaped by polling the handle. + +#include "hooks.h" + +#include "log.h" +#include "win_security.h" + +#include + +#include +#include +#include +#include + +namespace sendspin_cli { + +using sendspin::LogLevel; + +static constexpr const char* LOG_TAG = LOG_TAG_HOOK; + +namespace { + +/// Prefix of every hook variable; inherited ones are cleared first. +constexpr const wchar_t* ENV_PREFIX = L"SENDSPIN_"; + +/// `text`, which is UTF-8, as UTF-16. +std::wstring widen(const std::string& text) { + if (text.empty()) { + return {}; + } + const int size = + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); + std::wstring wide(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), wide.data(), size); + return wide; +} + +/// Appends `name=value` to `env`, or nothing when the value is empty. +void add_env(std::vector& env, const wchar_t* name, const std::string& value) { + if (value.empty()) { + return; + } + env.push_back(std::wstring(name) + L"=" + widen(value)); +} + +/// True if `entry` names its variable `prefix...`, compared as Windows does: without case. +bool has_prefix(const std::wstring& entry, const wchar_t* prefix) { + const size_t length = wcslen(prefix); + return entry.size() >= length && _wcsnicmp(entry.c_str(), prefix, length) == 0; +} + +/// Our environment minus inherited SENDSPIN_* variables, plus the event's own, as the +/// double-NUL-terminated block CreateProcessW() takes, sorted by name as it expects. +std::wstring hook_environment(const char* event, const HookContext& context) { + std::vector env; + if (wchar_t* block = GetEnvironmentStringsW(); block != nullptr) { + for (const wchar_t* entry = block; *entry != L'\0'; entry += wcslen(entry) + 1) { + // "=C:=C:\..." entries carry per-drive working directories; keep them. + if (!has_prefix(entry, ENV_PREFIX)) { + env.emplace_back(entry); + } + } + FreeEnvironmentStringsW(block); + } + add_env(env, L"SENDSPIN_EVENT", event); + add_env(env, L"SENDSPIN_SERVER_ID", context.server_id); + add_env(env, L"SENDSPIN_SERVER_NAME", context.server_name); + add_env(env, L"SENDSPIN_SERVER_URL", context.server_url); + add_env(env, L"SENDSPIN_CLIENT_ID", context.client_id); + add_env(env, L"SENDSPIN_CLIENT_NAME", context.client_name); + + std::sort(env.begin(), env.end(), [](const std::wstring& a, const std::wstring& b) { + return _wcsicmp(a.c_str(), b.c_str()) < 0; + }); + std::wstring block; + for (const std::wstring& entry : env) { + block += entry; + block += L'\0'; + } + block += L'\0'; + return block; +} + +/// The shell to run hooks with: %ComSpec%, else cmd.exe from the system directory. +std::wstring command_interpreter() { + wchar_t path[MAX_PATH]; + const DWORD length = GetEnvironmentVariableW(L"ComSpec", path, MAX_PATH); + if (length > 0 && length < MAX_PATH) { + return path; + } + const UINT system = GetSystemDirectoryW(path, MAX_PATH); + return (system > 0 && system < MAX_PATH ? std::wstring(path) + : std::wstring(L"C:\\Windows\\System32")) + + L"\\cmd.exe"; +} + +/// An inheritable copy of `handle`, or of NUL when it is not usable; the caller closes it. +HANDLE inheritable(HANDLE handle, HANDLE null_device) { + HANDLE source = (handle == nullptr || handle == INVALID_HANDLE_VALUE) ? null_device : handle; + HANDLE copy = nullptr; + if (DuplicateHandle(GetCurrentProcess(), source, GetCurrentProcess(), ©, 0, TRUE, + DUPLICATE_SAME_ACCESS) == 0) { + return nullptr; + } + return copy; +} + +} // namespace + +bool HookRunner::reap(RunningHook& hook) { + if (WaitForSingleObject(hook.process, 0) == WAIT_TIMEOUT) { + return false; + } + DWORD code = 0; + if (GetExitCodeProcess(hook.process, &code) == 0) { + cli_log(LogLevel::DEBUG, "The %s hook [%lu] could not be waited on (%s)", + hook.event.c_str(), hook.pid, last_error_text().c_str()); + } else if (code != 0) { + cli_log(LogLevel::WARN, "The %s hook [%lu] exited %lu", hook.event.c_str(), hook.pid, code); + } else { + cli_log(LogLevel::DEBUG, "The %s hook [%lu] finished", hook.event.c_str(), hook.pid); + } + CloseHandle(hook.process); + return true; +} + +void HookRunner::spawn(const std::string& command, const char* event, const HookContext& context) { + std::wstring environment = hook_environment(event, context); + // /s strips just the outer quotes, so the command keeps any of its own. + std::wstring command_line = + L"\"" + command_interpreter() + L"\" /d /s /c \"" + widen(command) + L"\""; + + // stdout may carry PCM, so point it at stderr; stdin reads nothing. + SECURITY_ATTRIBUTES null_attributes = {sizeof(SECURITY_ATTRIBUTES), nullptr, FALSE}; + const HANDLE null_device = + CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &null_attributes, OPEN_EXISTING, 0, nullptr); + HANDLE stdio[3] = { + inheritable(null_device, null_device), + inheritable(GetStdHandle(STD_ERROR_HANDLE), null_device), + inheritable(GetStdHandle(STD_ERROR_HANDLE), null_device), + }; + + // Only these three are inherited, so a hook cannot hold the player's sockets across a restart. + SIZE_T attribute_size = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_size); + std::vector attribute_buffer(attribute_size); + auto* attributes = reinterpret_cast(attribute_buffer.data()); + bool ready = stdio[0] != nullptr && stdio[1] != nullptr && stdio[2] != nullptr && + InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_size) != 0; + if (ready) { + ready = UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, stdio, + sizeof(stdio), nullptr, nullptr) != 0; + } + + STARTUPINFOEXW startup = {}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = stdio[0]; + startup.StartupInfo.hStdOutput = stdio[1]; + startup.StartupInfo.hStdError = stdio[2]; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process = {}; + // No window: a hook is not a console the user can see. + const bool started = + ready && + CreateProcessW(nullptr, command_line.data(), nullptr, nullptr, TRUE, + CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + environment.data(), nullptr, &startup.StartupInfo, &process) != 0; + const std::string reason = started ? std::string() : last_error_text(); + + if (ready) { + DeleteProcThreadAttributeList(attributes); + } + for (HANDLE handle : stdio) { + if (handle != nullptr) { + CloseHandle(handle); + } + } + if (null_device != INVALID_HANDLE_VALUE) { + CloseHandle(null_device); + } + + if (!started) { + cli_log(LogLevel::WARN, "Could not run the %s hook: %s", event, reason.c_str()); + return; + } + CloseHandle(process.hThread); + // Never log the command: it may carry credentials. + cli_log(LogLevel::DEBUG, "Running %s hook [%lu]", event, process.dwProcessId); + this->running_.push_back({process.dwProcessId, event, process.hProcess}); +} + +} // namespace sendspin_cli From 26141a06ea00890669a2afb9fe83c176cda60906 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:44:41 -0500 Subject: [PATCH 06/15] Advertise and discover over mDNS on Windows mdns_windns.cpp implements MdnsService over Windows' own DNS-SD API (DnsServiceRegister, DnsServiceBrowse, DnsServiceResolve; Windows 10 1809+), so nothing extra is installed. Its callbacks run on thread-pool threads, so they only post results, tagged with the operation they answer, to a queue that lives for the whole process; poll() applies them on the main loop, as the dns_sd implementation does, and drops any for an operation it no longer owns. A callback arriving after a cancel or after MdnsService is gone therefore never touches freed state. Discovery keeps mdns_dnssd.cpp's behaviour: the address settle window, resolution order, a zero-TTL goodbye removing a server, and the retry backoff for a refused registration or browse. stop() deregisters and waits up to 500 ms for the goodbye to go out. --- CMakeLists.txt | 17 +- src/mdns_windns.cpp | 675 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 src/mdns_windns.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e0753d..9ca37dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -204,7 +204,8 @@ endif() # mDNS -# Auto-detected: Bonjour on macOS, libavahi-compat-libdnssd on Linux. OFF forces it out. +# Auto-detected: Bonjour on macOS, libavahi-compat-libdnssd on Linux, windns on Windows. OFF +# forces it out. option(SENDSPIN_CLI_WITH_MDNS "Build mDNS advertisement/discovery if dns_sd.h is available" ON) set(SENDSPIN_CLI_MDNS_ENABLED OFF) @@ -212,9 +213,9 @@ set(SENDSPIN_CLI_MDNS_DESCRIPTION "none") if(SENDSPIN_CLI_WITH_MDNS) if(WIN32) - message(STATUS - "sendspin-cli: no mDNS on Windows yet -- the player cannot be discovered and -s " - "mdns: is refused at parse time.") + # Windows 10 1809+ has DNS-SD in dnsapi, so there is nothing to find. + set(SENDSPIN_CLI_MDNS_ENABLED ON) + set(SENDSPIN_CLI_MDNS_DESCRIPTION "windns (built in)") elseif(APPLE) # dns_sd lives in libSystem here, so there is nothing to find and nothing to link. set(SENDSPIN_CLI_MDNS_ENABLED ON) @@ -236,7 +237,7 @@ if(SENDSPIN_CLI_WITH_MDNS) endif() # Bonjour-only error enumerators, probed by compiling a use: #ifdef cannot see enumerators. -if(SENDSPIN_CLI_MDNS_ENABLED) +if(SENDSPIN_CLI_MDNS_ENABLED AND NOT WIN32) include(CheckCXXSourceCompiles) # The same dns_sd.h include directory the build uses. @@ -308,7 +309,11 @@ else() endif() # One MdnsService implementation per build. -if(SENDSPIN_CLI_MDNS_ENABLED) +if(SENDSPIN_CLI_MDNS_ENABLED AND WIN32) + target_sources(sendspin-cli-core PRIVATE src/mdns_windns.cpp) + target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_MDNS) + target_link_libraries(sendspin-cli-core PUBLIC dnsapi) +elseif(SENDSPIN_CLI_MDNS_ENABLED) target_sources(sendspin-cli-core PRIVATE src/mdns_dnssd.cpp) target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_MDNS) # PRIVATE: only mdns_dnssd.cpp reads these. diff --git a/src/mdns_windns.cpp b/src/mdns_windns.cpp new file mode 100644 index 0000000..98d4bff --- /dev/null +++ b/src/mdns_windns.cpp @@ -0,0 +1,675 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// MdnsService over Windows' own DNS-SD API (windns.h, Windows 10 1809+). +/// Its callbacks run on thread-pool threads, so they only queue results for poll() to apply. + +#include "mdns.h" + +#include "log.h" +#include "outbound.h" + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin_cli { + +using sendspin::LogLevel; + +/// Advertising lines; discovery lines log under LOG_TAG_DISCOVERY. +static constexpr const char* LOG_TAG = LOG_TAG_MDNS; + +namespace { + +/// Wait after a candidate's first address, as mdns_dnssd.cpp does. +constexpr int64_t ADDRESS_SETTLE_MS = 250; + +/// How long stop() waits for the deregistration to send its goodbye. +constexpr DWORD DEREGISTER_WAIT_MS = 500; + +constexpr const char* MDNS_DOMAIN_SUFFIX = ".local"; + +std::wstring widen(const std::string& text) { + if (text.empty()) { + return {}; + } + const int size = + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); + std::wstring wide(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), wide.data(), size); + return wide; +} + +std::string narrow(const wchar_t* text) { + if (text == nullptr || *text == L'\0') { + return {}; + } + const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr); + std::string out(static_cast(size), '\0'); + WideCharToMultiByte(CP_UTF8, 0, text, -1, out.data(), size, nullptr, nullptr); + out.pop_back(); + return out; +} + +std::string describe_status(DWORD status) { + char text[256] = {}; + const DWORD length = FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, status, 0, text, sizeof(text), nullptr); + std::string message(text, length); + while (!message.empty() && + (message.back() == '\n' || message.back() == '\r' || message.back() == '.')) { + message.pop_back(); + } + return message.empty() ? "DNS status " + std::to_string(status) : message; +} + +/// `full` without its `..local` suffix, or empty when it is not one of `service`'s. +std::string instance_label(const std::string& full, const std::string& service) { + const std::string suffix = "." + service + MDNS_DOMAIN_SUFFIX; + if (full.size() <= suffix.size() || + full.compare(full.size() - suffix.size(), suffix.size(), suffix) != 0) { + return {}; + } + return full.substr(0, full.size() - suffix.size()); +} + +// The queue callbacks post to. Process-lifetime, so a callback that fires after its +// MdnsService is gone, or after a cancel, still has somewhere valid to write. + +enum class EventKind { Registered, Deregistered, Browsed, Resolved }; + +struct Event { + EventKind kind{EventKind::Browsed}; + uint64_t op{0}; ///< which operation it answers; stale ones are dropped + DWORD status{0}; + std::string instance; ///< registered or resolved instance label + std::vector> browsed; ///< full name, still present + std::string host; + uint16_t port{0}; + std::string path; + std::string name; + std::vector addresses; +}; + +struct Mailbox { + std::mutex mutex; + std::deque events; + HANDLE deregistered{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; +}; + +/// Never destroyed: callbacks may outlive every MdnsService. +Mailbox& mailbox() { + static auto* const instance = new Mailbox(); + return *instance; +} + +void post(Event event) { + Mailbox& box = mailbox(); + if (event.kind == EventKind::Deregistered) { + SetEvent(box.deregistered); + } + const std::lock_guard lock(box.mutex); + box.events.push_back(std::move(event)); +} + +uint64_t op_of(void* context) { + return static_cast(reinterpret_cast(context)); +} + +void* context_of(uint64_t op) { + return reinterpret_cast(static_cast(op)); +} + +/// The instance's TXT value for `key`, or empty. +std::string property(const DNS_SERVICE_INSTANCE& instance, const wchar_t* key) { + for (DWORD i = 0; i < instance.dwPropertyCount; ++i) { + if (instance.keys[i] != nullptr && _wcsicmp(instance.keys[i], key) == 0) { + return narrow(instance.values[i]); + } + } + return {}; +} + +VOID WINAPI register_complete(DWORD status, PVOID context, PDNS_SERVICE_INSTANCE instance) { + Event event; + event.kind = EventKind::Registered; + event.op = op_of(context); + event.status = status; + if (instance != nullptr) { + event.instance = narrow(instance->pszInstanceName); + DnsServiceFreeInstance(instance); + } + post(std::move(event)); +} + +VOID WINAPI deregister_complete(DWORD status, PVOID context, PDNS_SERVICE_INSTANCE instance) { + Event event; + event.kind = EventKind::Deregistered; + event.op = op_of(context); + event.status = status; + if (instance != nullptr) { + DnsServiceFreeInstance(instance); + } + post(std::move(event)); +} + +VOID WINAPI browse_complete(DWORD status, PVOID context, PDNS_RECORD records) { + Event event; + event.kind = EventKind::Browsed; + event.op = op_of(context); + event.status = status; + // The DNS-SD API is Unicode-only, whatever PDNS_RECORD names in this build. + for (const auto* record = reinterpret_cast(records); record != nullptr; + record = record->pNext) { + if (record->wType == DNS_TYPE_PTR && record->Data.PTR.pNameHost != nullptr) { + // A zero TTL is the record's goodbye. + event.browsed.emplace_back(narrow(record->Data.PTR.pNameHost), record->dwTtl != 0); + } + } + if (records != nullptr) { + DnsRecordListFree(records, DnsFreeRecordList); + } + post(std::move(event)); +} + +VOID WINAPI resolve_complete(DWORD status, PVOID context, PDNS_SERVICE_INSTANCE instance) { + Event event; + event.kind = EventKind::Resolved; + event.op = op_of(context); + event.status = status; + if (instance != nullptr) { + event.host = narrow(instance->pszHostName); + event.port = instance->wPort; + event.path = property(*instance, L"path"); + event.name = property(*instance, L"name"); + char text[INET6_ADDRSTRLEN] = {}; + if (instance->ip4Address != nullptr && + inet_ntop(AF_INET, instance->ip4Address, text, sizeof(text)) != nullptr) { + event.addresses.emplace_back(text); + } + if (instance->ip6Address != nullptr && + inet_ntop(AF_INET6, instance->ip6Address, text, sizeof(text)) != nullptr) { + event.addresses.emplace_back(text); + } + DnsServiceFreeInstance(instance); + } + post(std::move(event)); +} + +/// This machine's mDNS host name, `.local`. +std::wstring local_host_name() { + wchar_t name[256]; + DWORD size = 256; + if (GetComputerNameExW(ComputerNameDnsHostname, name, &size) == 0 || size == 0) { + return L"localhost.local"; + } + return std::wstring(name) + widen(MDNS_DOMAIN_SUFFIX); +} + +} // namespace + +/// All windns state, kept out of the header for the null build. +struct MdnsService::Impl { + /// A `_sendspin-server._tcp` instance being resolved, or already resolved. + struct Candidate { + std::string instance; + std::string name; + std::string path; + uint16_t port{0}; + std::vector addresses; + + uint64_t resolve_op{0}; ///< non-zero while its resolve is outstanding + DNS_SERVICE_CANCEL resolve_cancel{}; + std::wstring resolve_name; ///< kept alive for the request that points at it + DNS_SERVICE_RESOLVE_REQUEST resolve_request{}; + + int64_t first_address_ms{-1}; + uint64_t resolved_seq{0}; ///< 0 until ready; otherwise the order it resolved in + bool announced{false}; ///< whether the "found" line has already been logged + }; + + // What was asked for, kept to retry. + bool advertise_wanted{false}; + std::string advertise_instance; + std::string advertise_path; + std::string advertise_friendly_name; + uint16_t advertise_port{0}; + bool browse_wanted{false}; + + // Live windns state. + uint64_t register_op{0}; + PDNS_SERVICE_INSTANCE register_instance{nullptr}; + DNS_SERVICE_REGISTER_REQUEST register_request{}; + uint64_t browse_op{0}; + DNS_SERVICE_CANCEL browse_cancel{}; + std::wstring browse_name; + DNS_SERVICE_BROWSE_REQUEST browse_request{}; + std::map> candidates; + + uint64_t next_op{1}; + uint64_t next_seq{1}; + + // Recovery. + int64_t restart_at_ms{-1}; + uint32_t restart_attempt{0}; + + ~Impl() { + this->teardown(); + } + + bool start_register(std::string& error) { + const std::string instance = truncate_utf8(this->advertise_instance, MDNS_MAX_LABEL_BYTES); + const std::string path = truncate_utf8(this->advertise_path, MDNS_MAX_TXT_VALUE_BYTES); + const std::string friendly = + truncate_utf8(this->advertise_friendly_name, MDNS_MAX_TXT_VALUE_BYTES); + if (instance.size() < this->advertise_instance.size()) { + cli_log(LogLevel::WARN, + "instance name is longer than the %zu-byte DNS label limit -- " + "advertising as \"%s\"", + MDNS_MAX_LABEL_BYTES, instance.c_str()); + } + + const std::wstring service = + widen(instance + "." + MDNS_CLIENT_SERVICE + MDNS_DOMAIN_SUFFIX); + const std::wstring host = local_host_name(); + const std::wstring path_value = widen(path); + const std::wstring name_value = widen(friendly); + PCWSTR keys[] = {L"path", L"name"}; + PCWSTR values[] = {path_value.c_str(), name_value.c_str()}; + const DWORD properties = friendly.empty() ? 1 : 2; + + this->register_instance = + DnsServiceConstructInstance(service.c_str(), host.c_str(), nullptr, nullptr, + this->advertise_port, 0, 0, properties, keys, values); + if (this->register_instance == nullptr) { + error = "could not register " + std::string(MDNS_CLIENT_SERVICE) + + ": the service instance could not be built"; + return false; + } + + const uint64_t op = this->next_op++; + this->register_request = {}; + this->register_request.Version = DNS_QUERY_REQUEST_VERSION1; + this->register_request.InterfaceIndex = 0; + this->register_request.pServiceInstance = this->register_instance; + this->register_request.pRegisterCompletionCallback = register_complete; + this->register_request.pQueryContext = context_of(op); + const DWORD status = DnsServiceRegister(&this->register_request, nullptr); + if (status != DNS_REQUEST_PENDING) { + DnsServiceFreeInstance(this->register_instance); + this->register_instance = nullptr; + error = "could not register " + std::string(MDNS_CLIENT_SERVICE) + ": " + + describe_status(status); + return false; + } + this->register_op = op; + return true; + } + + bool start_browse(std::string& error) { + this->browse_name = widen(std::string(MDNS_SERVER_SERVICE) + MDNS_DOMAIN_SUFFIX); + const uint64_t op = this->next_op++; + // Kept in Impl, not on the stack: it must outlive the call. + DNS_SERVICE_BROWSE_REQUEST& request = this->browse_request; + request = {}; + request.Version = DNS_QUERY_REQUEST_VERSION1; + request.InterfaceIndex = 0; + request.QueryName = this->browse_name.c_str(); + request.pBrowseCallback = browse_complete; + request.pQueryContext = context_of(op); + this->browse_cancel = {}; + const DNS_STATUS status = DnsServiceBrowse(&request, &this->browse_cancel); + if (status != DNS_REQUEST_PENDING) { + error = "could not browse " + std::string(MDNS_SERVER_SERVICE) + ": " + + describe_status(status); + return false; + } + this->browse_op = op; + // A browse has no confirming callback, so reset the backoff here. + this->restart_attempt = 0; + return true; + } + + void start_resolve(Candidate& candidate) { + candidate.resolve_name = + widen(candidate.instance + "." + MDNS_SERVER_SERVICE + MDNS_DOMAIN_SUFFIX); + const uint64_t op = this->next_op++; + DNS_SERVICE_RESOLVE_REQUEST& request = candidate.resolve_request; + request = {}; + request.Version = DNS_QUERY_REQUEST_VERSION1; + request.InterfaceIndex = 0; + request.QueryName = candidate.resolve_name.data(); + request.pResolveCompletionCallback = resolve_complete; + request.pQueryContext = context_of(op); + candidate.resolve_cancel = {}; + const DNS_STATUS status = DnsServiceResolve(&request, &candidate.resolve_cancel); + if (status != DNS_REQUEST_PENDING) { + log_line(LogLevel::WARN, LOG_TAG_DISCOVERY, "could not resolve \"%s\" (%s)", + candidate.instance.c_str(), describe_status(status).c_str()); + return; + } + candidate.resolve_op = op; + } + + void cancel_resolve(Candidate& candidate) { + if (candidate.resolve_op != 0) { + DnsServiceResolveCancel(&candidate.resolve_cancel); + candidate.resolve_op = 0; + } + } + + void teardown() { + for (auto& entry : this->candidates) { + this->cancel_resolve(*entry.second); + } + this->candidates.clear(); + + if (this->browse_op != 0) { + DnsServiceBrowseCancel(&this->browse_cancel); + this->browse_op = 0; + } + + if (this->register_instance != nullptr) { + // Deregistering sends the goodbye; give it a moment before the request is freed. + const uint64_t op = this->next_op++; + this->register_request.pRegisterCompletionCallback = deregister_complete; + this->register_request.pQueryContext = context_of(op); + ResetEvent(mailbox().deregistered); + if (DnsServiceDeRegister(&this->register_request, nullptr) == DNS_REQUEST_PENDING) { + WaitForSingleObject(mailbox().deregistered, DEREGISTER_WAIT_MS); + } + DnsServiceFreeInstance(this->register_instance); + this->register_instance = nullptr; + } + this->register_op = 0; + } + + void schedule_restart(int64_t now_ms) { + this->restart_at_ms = + now_ms + static_cast(next_retry_delay_ms(this->restart_attempt)); + if (next_retry_delay_ms(this->restart_attempt) < MAX_RETRY_DELAY_MS) { + ++this->restart_attempt; + } + } + + /// Starts only what is wanted and not running. + void restart() { + std::string error; + if (this->advertise_wanted && this->register_op == 0 && !this->start_register(error)) { + cli_log(LogLevel::WARN, "%s", error.c_str()); + } + if (this->browse_wanted && this->browse_op == 0 && !this->start_browse(error)) { + log_line(LogLevel::WARN, LOG_TAG_DISCOVERY, "%s", error.c_str()); + } + } + + void poll_once(int64_t now_ms) { + // Anything wanted but not running is owed a retry. + const bool missing = (this->advertise_wanted && this->register_op == 0) || + (this->browse_wanted && this->browse_op == 0); + if (missing && this->restart_at_ms < 0) { + this->schedule_restart(now_ms); + } + if (this->restart_at_ms >= 0 && now_ms >= this->restart_at_ms) { + this->restart_at_ms = -1; + this->restart(); + } + + std::deque events; + { + Mailbox& box = mailbox(); + const std::lock_guard lock(box.mutex); + events.swap(box.events); + } + for (Event& event : events) { + this->apply(event, now_ms); + } + this->settle_addresses(now_ms); + } + + void apply(Event& event, int64_t now_ms) { + switch (event.kind) { + case EventKind::Registered: + this->on_registered(event); + break; + case EventKind::Deregistered: + break; + case EventKind::Browsed: + this->on_browsed(event); + break; + case EventKind::Resolved: + this->on_resolved(event, now_ms); + break; + } + } + + void on_registered(const Event& event) { + if (event.op != this->register_op) { + return; + } + if (event.status != ERROR_SUCCESS) { + // Dropped, so poll() retries it on the backoff. + cli_log(LogLevel::ERROR, "registration was refused (%s)", + describe_status(event.status).c_str()); + DnsServiceFreeInstance(this->register_instance); + this->register_instance = nullptr; + this->register_op = 0; + return; + } + const std::string label = instance_label(event.instance, MDNS_CLIENT_SERVICE); + cli_log(LogLevel::INFO, "advertising %s as \"%s\" on port %u (path %s)", + MDNS_CLIENT_SERVICE, label.empty() ? event.instance.c_str() : label.c_str(), + this->advertise_port, this->advertise_path.c_str()); + this->restart_attempt = 0; + } + + void on_browsed(const Event& event) { + if (event.op != this->browse_op) { + return; + } + if (event.status != ERROR_SUCCESS) { + log_line(LogLevel::WARN, LOG_TAG_DISCOVERY, "browse reported an error (%s)", + describe_status(event.status).c_str()); + return; + } + for (const auto& [full, present] : event.browsed) { + const std::string instance = instance_label(full, MDNS_SERVER_SERVICE); + if (instance.empty()) { + continue; + } + log_line(LogLevel::DEBUG, LOG_TAG_DISCOVERY, "browse %s \"%s\"", + present ? "found" : "lost", instance.c_str()); + if (!present) { + this->forget(instance); + continue; + } + if (this->candidates.count(instance) != 0) { + continue; + } + auto candidate = std::make_unique(); + candidate->instance = instance; + this->start_resolve(*candidate); + if (candidate->resolve_op != 0) { + this->candidates.emplace(instance, std::move(candidate)); + } + } + } + + void on_resolved(const Event& event, int64_t now_ms) { + Candidate* candidate = nullptr; + for (auto& entry : this->candidates) { + if (entry.second->resolve_op == event.op && event.op != 0) { + candidate = entry.second.get(); + break; + } + } + if (candidate == nullptr) { + return; + } + candidate->resolve_op = 0; + + if (event.status != ERROR_SUCCESS) { + log_line(LogLevel::WARN, LOG_TAG_DISCOVERY, "resolving \"%s\" failed (%s)", + candidate->instance.c_str(), describe_status(event.status).c_str()); + // Dropped, so a later browse result can add it again. + this->candidates.erase(candidate->instance); + return; + } + + log_line(LogLevel::DEBUG, LOG_TAG_DISCOVERY, "resolved \"%s\" to %s:%u", + candidate->instance.c_str(), event.host.c_str(), event.port); + candidate->port = event.port; + candidate->path = event.path; + candidate->name = event.name; + candidate->addresses = event.addresses; + if (candidate->addresses.empty()) { + log_line(LogLevel::WARN, LOG_TAG_DISCOVERY, + "no address came back for \"%s\" (%s) -- dropping", + candidate->instance.c_str(), event.host.c_str()); + this->candidates.erase(candidate->instance); + return; + } + candidate->first_address_ms = now_ms; + } + + /// Marks candidates dialable once their addresses have had time to all arrive. + void settle_addresses(int64_t now_ms) { + for (auto& entry : this->candidates) { + Candidate& candidate = *entry.second; + if (candidate.resolved_seq != 0 || candidate.first_address_ms < 0 || + now_ms - candidate.first_address_ms < ADDRESS_SETTLE_MS) { + continue; + } + std::string url; + std::string error; + DiscoveredServer server; + this->fill(candidate, server); + if (!discovered_server_url(server, url, error)) { + if (!candidate.announced) { + candidate.announced = true; + log_line(LogLevel::DEBUG, LOG_TAG_DISCOVERY, "skipping server \"%s\" -- %s", + candidate.instance.c_str(), error.c_str()); + } + continue; + } + candidate.resolved_seq = this->next_seq++; + candidate.announced = true; + log_line(LogLevel::INFO, LOG_TAG_DISCOVERY, "found server \"%s\" (name: %s) at %s", + candidate.instance.c_str(), + candidate.name.empty() ? "" : candidate.name.c_str(), url.c_str()); + } + } + + void fill(const Candidate& candidate, DiscoveredServer& out) const { + out.instance = candidate.instance; + out.name = candidate.name; + out.path = candidate.path; + out.port = candidate.port; + out.addresses = candidate.addresses; + } + + void forget(const std::string& instance) { + const auto found = this->candidates.find(instance); + if (found == this->candidates.end()) { + return; + } + if (found->second->resolved_seq != 0) { + log_line(LogLevel::INFO, LOG_TAG_DISCOVERY, "server \"%s\" went away", + instance.c_str()); + } + this->cancel_resolve(*found->second); + this->candidates.erase(found); + } +}; + +MdnsService::MdnsService() : impl_(std::make_unique()) {} + +MdnsService::~MdnsService() = default; + +bool MdnsService::advertise(const std::string& instance, uint16_t port, const std::string& path, + const std::string& friendly_name, std::string& error) { + this->impl_->advertise_wanted = true; + this->impl_->advertise_instance = instance; + this->impl_->advertise_port = port; + this->impl_->advertise_path = path; + this->impl_->advertise_friendly_name = friendly_name; + return this->impl_->start_register(error); +} + +bool MdnsService::browse(std::string& error) { + this->impl_->browse_wanted = true; + return this->impl_->start_browse(error); +} + +void MdnsService::poll(int64_t now_ms) { + this->impl_->poll_once(now_ms); +} + +std::vector MdnsService::servers() const { + std::vector ready; + for (const auto& entry : this->impl_->candidates) { + if (entry.second->resolved_seq != 0) { + ready.push_back(entry.second.get()); + } + } + // The map is ordered by instance name, so the resolution order has to be restored here. + std::sort(ready.begin(), ready.end(), [](const Impl::Candidate* a, const Impl::Candidate* b) { + return a->resolved_seq < b->resolved_seq; + }); + + std::vector servers; + servers.reserve(ready.size()); + for (const Impl::Candidate* candidate : ready) { + DiscoveredServer server; + this->impl_->fill(*candidate, server); + std::string url; + std::string error; + if (!discovered_server_url(server, url, error)) { + continue; + } + servers.push_back(std::move(server)); + } + return servers; +} + +void MdnsService::stop() { + this->impl_->advertise_wanted = false; + this->impl_->browse_wanted = false; + this->impl_->restart_at_ms = -1; + this->impl_->teardown(); +} + +bool mdns_available() { + return true; +} + +std::string mdns_backend_name() { + return "windns (Windows DNS-SD)"; +} + +} // namespace sendspin_cli From bccc467e4af8d0691f3261dec13f19f11d4e85dd Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:55:38 -0500 Subject: [PATCH 07/15] Fix what the test suite found on Windows - getopt_win.h permutes argv as glibc's and the BSDs' getopt_long() do, so a flag after a positional argument (extra --help) is still parsed - -z leaves a rooted path as typed and its detached copy moves to the root of the same drive, so '/run/x.pid' names what it did; relative paths are made absolute with '/' separators - The -f logfile is opened in binary, so its lines end in \n as on POSIX - A directory the ACL check refuses is reported as 'writable by', the wording the POSIX check uses --- src/cli.cpp | 10 +++++--- src/daemon_win.cpp | 10 +++----- src/getopt_win.h | 58 +++++++++++++++++++++++++++++++++++++++++--- src/log.cpp | 5 +++- src/win_security.cpp | 2 +- 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 23caeed..b00a788 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -510,10 +510,14 @@ bool merge_config(const ConfigFile& config, bool subcommand_run, Options& out, /// `path` made absolute against the cwd, for -z: the daemon chdir()s to /. std::string absolute_path(const std::string& path) { #ifdef _WIN32 - // Drive letters, UNC paths and \-rooted paths all need more than a leading-'/' test. + // Rooted paths stay as typed: the detached copy moves to the root of this same drive. + const std::filesystem::path typed(path); + if (path.empty() || typed.has_root_directory()) { + return path; + } std::error_code ignored; - const std::filesystem::path absolute = std::filesystem::absolute(path, ignored); - return absolute.empty() ? path : absolute.string(); + const std::filesystem::path absolute = std::filesystem::absolute(typed, ignored); + return absolute.empty() ? path : absolute.generic_string(); #else if (!path.empty() && path.front() == '/') { return path; diff --git a/src/daemon_win.cpp b/src/daemon_win.cpp index 7244a66..68c2556 100644 --- a/src/daemon_win.cpp +++ b/src/daemon_win.cpp @@ -128,17 +128,13 @@ PidFileStatus probe_pidfile(const std::string& path, std::string& error) { } bool daemonize(bool /*discard_stderr*/, std::string& error) { - // The system drive's root, as chdir("/") does: nothing keeps the launch directory busy. - wchar_t root[MAX_PATH] = L"C:\\"; - if (GetSystemWindowsDirectoryW(root, MAX_PATH) >= 3) { - root[3] = L'\0'; - } - // The copy started below re-runs main() from the same command line; the marker says so. // It starts in the launch directory, so its relative paths absolutize as the parent's did. if (GetEnvironmentVariableW(DETACHED_MARKER, nullptr, 0) != 0) { SetEnvironmentVariableW(DETACHED_MARKER, nullptr); - if (SetCurrentDirectoryW(root) == 0) { + // This drive's root, as chdir("/") does: nothing keeps the launch directory busy, and a + // path rooted without a drive letter still names what it did. + if (SetCurrentDirectoryW(L"\\") == 0) { error = "cannot change directory to the drive root: " + last_error_text(); return false; } diff --git a/src/getopt_win.h b/src/getopt_win.h index 6fe2929..767eced 100644 --- a/src/getopt_win.h +++ b/src/getopt_win.h @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// getopt_long() for Windows, which has none: the subset cli.cpp uses, with BSD semantics -/// (no argument permutation, unique long-option prefixes, optreset). +/// getopt_long() for Windows, which has none: the subset cli.cpp uses, behaving as glibc's and +/// the BSDs' do (argv permuted so options come first, unique long-option prefixes, optreset). #pragma once +#include #include inline char* optarg = nullptr; @@ -62,6 +63,53 @@ inline int missing_value(const char* optstring) { return optstring[0] == ':' ? ':' : '?'; } +inline bool is_option(const char* word) { + return word[0] == '-' && word[1] != '\0'; +} + +/// How many words the option word at `index` takes: 2 when its value is the next word. +inline int option_words(int argc, char* const argv[], int index, const char* optstring, + const option* longopts) { + const char* word = argv[index]; + if (index + 1 >= argc) { + return 1; + } + if (word[1] == '-') { + const char* name = word + 2; + if (*name == '\0' || std::strchr(name, '=') != nullptr) { + return 1; + } + const option* opt = find_long(longopts, name, std::strlen(name)); + return opt != nullptr && opt->has_arg == required_argument ? 2 : 1; + } + for (const char* c = word + 1; *c != '\0'; ++c) { + const char* spec = *c == ':' ? nullptr : std::strchr(optstring, *c); + if (spec != nullptr && spec[1] == ':') { + // The rest of the word is the value, or the next word is. + return c[1] == '\0' ? 2 : 1; + } + } + return 1; +} + +/// Moves the next option (and its value word) in front of the non-options at optind. +/// @return false when no option is left, which leaves optind on the first non-option. +inline bool permute(int argc, char* const argv[], const char* optstring, const option* longopts) { + int next = optind; + while (next < argc && !is_option(argv[next])) { + ++next; + } + if (next >= argc) { + return false; + } + const int words = std::strcmp(argv[next], "--") == 0 + ? 1 + : option_words(argc, argv, next, optstring, longopts); + char** const words_start = const_cast(argv); + std::rotate(words_start + optind, words_start + next, words_start + next + words); + return true; +} + } // namespace getopt_win inline int getopt_long(int argc, char* const argv[], const char* optstring, const option* longopts, @@ -75,7 +123,11 @@ inline int getopt_long(int argc, char* const argv[], const char* optstring, cons optopt = 0; if (getopt_win::short_pos == 1) { - if (optind >= argc || argv[optind][0] != '-' || argv[optind][1] == '\0') { + if (optind >= argc) { + return -1; + } + if (!getopt_win::is_option(argv[optind]) && + !getopt_win::permute(argc, argv, optstring, longopts)) { return -1; } if (std::strcmp(argv[optind], "--") == 0) { diff --git a/src/log.cpp b/src/log.cpp index a9b9c25..17eafe0 100644 --- a/src/log.cpp +++ b/src/log.cpp @@ -22,6 +22,8 @@ #define STDERR_FILENO 2 #else #include +// Only Windows translates line endings. +#define O_BINARY 0 #endif #include @@ -119,7 +121,8 @@ __attribute__((format(printf, 3, 0))) void emit(sendspin::LogLevel level, const /// Points fd 2 at `path`, appending, and leaves it alone if the file cannot be opened. /// @return true on success; on false errno is the reason. bool point_stderr_at(const std::string& path) { - const int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); + // Binary, so every line ends in \n on Windows too. + const int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC | O_BINARY, 0644); if (fd < 0) { return false; } diff --git a/src/win_security.cpp b/src/win_security.cpp index c3d9eae..4b186c5 100644 --- a/src/win_security.cpp +++ b/src/win_security.cpp @@ -195,7 +195,7 @@ bool is_private_directory(const std::string& path, std::string& reason) { is_well_known(trustee, WinCreatorOwnerSid)) { continue; } - reason = path + " grants " + account_name(trustee) + " write access"; + reason = path + " is writable by " + account_name(trustee); return false; } return true; From 72990f92f1c0f6e25599ccf1ded54d4477ebb3d0 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:55:38 -0500 Subject: [PATCH 08/15] Run the test suite on Windows - posix_compat.h gives the fixtures mkdir(path, mode), STDERR_FILENO, F_OK, O_BINARY and mode_t; file_access.h turns modes into ACLs: an owner-only check for the 0600 tests, and a deny ACE for the chmod 0500 tests - Windows versions of the tests whose expectation is the platform's own: the config and state search paths, the shared folder in place of /tmp, and the hosts file in place of /etc/hosts - Hook tests in cmd.exe, and a handle-inheritance test in place of the descriptor one; the SIGPIPE test and log rotation on SIGHUP stay POSIX-only - --config defaults to NUL, Windows' /dev/null - GoogleTest uses the shared CRT and no /WX under clang-cl 512 tests pass on Windows; the symlink test is skipped, since creating a symlink there needs Developer Mode or an elevated process. --- CMakeLists.txt | 7 +++ tests/cli_test.cpp | 6 +- tests/config_file_test.cpp | 36 +++++++++-- tests/control_test.cpp | 50 +++++++++++++-- tests/daemon_test.cpp | 17 ++++- tests/file_access.h | 111 +++++++++++++++++++++++++++++++++ tests/hooks_test.cpp | 103 +++++++++++++++++++++++++++--- tests/parse_harness.h | 23 ++++++- tests/player_listener_test.cpp | 3 +- tests/posix_compat.h | 39 ++++++++++++ tests/scoped_env.h | 25 ++++++-- tests/state_store_test.cpp | 44 ++++++++++--- 12 files changed, 425 insertions(+), 39 deletions(-) create mode 100644 tests/file_access.h create mode 100644 tests/posix_compat.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ca37dc..19703e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -468,7 +468,14 @@ if(SENDSPIN_CLI_BUILD_TESTS) ) set(INSTALL_GTEST OFF CACHE BOOL "Install GoogleTest alongside our own targets") set(BUILD_GMOCK OFF CACHE BOOL "Build GoogleMock") + # The CRT the rest of the build links (/MD), not GoogleTest's own /MT default. + set(gtest_force_shared_crt ON CACHE BOOL "Use the shared CRT for GoogleTest") FetchContent_MakeAvailable(googletest) + if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + # GoogleTest builds itself with /WX, which newer clang-cl warnings break. + target_compile_options(gtest PRIVATE -Wno-error) + target_compile_options(gtest_main PRIVATE -Wno-error) + endif() enable_testing() include(GoogleTest) diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp index 5abeeea..c3e14c3 100644 --- a/tests/cli_test.cpp +++ b/tests/cli_test.cpp @@ -603,8 +603,8 @@ TEST(ParseOptions, DaemonizeMakesRelativePidfileAndLogfilePathsAbsolute) { Parse parse({"-z", "-o", "null", "-P", "sendspin.pid", "-f", "sendspin.log"}); ASSERT_TRUE(parse.ok()) << parse.diagnostics(); - EXPECT_EQ(parse.options().pidfile.front(), '/') << parse.options().pidfile; - EXPECT_EQ(parse.options().logfile.front(), '/') << parse.options().logfile; + EXPECT_TRUE(is_absolute_path(parse.options().pidfile)) << parse.options().pidfile; + EXPECT_TRUE(is_absolute_path(parse.options().logfile)) << parse.options().logfile; EXPECT_NE(parse.options().pidfile.find("/sendspin.pid"), std::string::npos) << parse.options().pidfile; EXPECT_NE(parse.options().logfile.find("/sendspin.log"), std::string::npos) @@ -1192,7 +1192,7 @@ TEST(ParseOptions, ARelativeControlSocketIsMadeAbsoluteUnderZ) { Parse parse({"-z", "-f", "/tmp/log", "--control-socket", "mine.sock"}); ASSERT_TRUE(parse.ok()) << parse.diagnostics(); - EXPECT_EQ(parse.options().control_socket.front(), '/'); + EXPECT_TRUE(is_absolute_path(parse.options().control_socket)) << parse.options().control_socket; EXPECT_NE(parse.options().control_socket.find("/mine.sock"), std::string::npos) << parse.options().control_socket; } diff --git a/tests/config_file_test.cpp b/tests/config_file_test.cpp index bdaf6ab..7df2bd2 100644 --- a/tests/config_file_test.cpp +++ b/tests/config_file_test.cpp @@ -19,11 +19,14 @@ #include "cli.h" #include "control.h" #include "parse_harness.h" +#include "posix_compat.h" #include "scoped_env.h" +#ifdef _WIN32 +#include "win_security.h" +#endif + #include -#include -#include #include #include @@ -101,6 +104,26 @@ class ScratchDir { // Where a config file is looked for +#ifdef _WIN32 +TEST(ConfigSearchPaths, RunsXdgThenRoamingAppData) { + const ScopedEnv xdg("XDG_CONFIG_HOME", "/xdg/config"); + + const std::vector paths = config_search_paths(); + ASSERT_EQ(paths.size(), 2U); + EXPECT_EQ(paths[0], "/xdg/config/sendspin-cli/config"); + EXPECT_EQ(paths[1], roaming_app_data_dir() + "/sendspin-cli/config"); +} + +TEST(ConfigSearchPaths, IgnoresHomeAndHasNoSystemFile) { + const ScopedEnv xdg("XDG_CONFIG_HOME", nullptr); + const ScopedEnv home("HOME", "/home/someone"); + + // Any user may create files under ProgramData, so there is no system-wide file to trust. + const std::vector paths = config_search_paths(); + ASSERT_EQ(paths.size(), 1U); + EXPECT_EQ(paths[0], roaming_app_data_dir() + "/sendspin-cli/config"); +} +#else TEST(ConfigSearchPaths, RunsXdgThenHomeThenEtc) { const ScopedEnv xdg("XDG_CONFIG_HOME", "/xdg/config"); const ScopedEnv home("HOME", "/home/someone"); @@ -121,6 +144,7 @@ TEST(ConfigSearchPaths, AnEmptyOrUnsetVariableContributesNothing) { ASSERT_EQ(paths.size(), 1U); EXPECT_EQ(paths[0], SYSTEM_CONFIG_PATH); } +#endif TEST(LoadConfigFile, TakesTheFirstFileFoundAndStopsThere) { ScratchDir scratch; @@ -423,7 +447,11 @@ TEST(LongAliases, AreListedByHelpAlongsideTheConfigSearchPath) { EXPECT_NE(text.find(flag), std::string::npos) << flag; } EXPECT_NE(text.find("/xdg/config/sendspin-cli/config"), std::string::npos); +#ifdef _WIN32 + EXPECT_NE(text.find(roaming_app_data_dir() + "/sendspin-cli/config"), std::string::npos); +#else EXPECT_NE(text.find(SYSTEM_CONFIG_PATH), std::string::npos); +#endif } // A configured value is validated by the same code a typed one is @@ -567,7 +595,7 @@ TEST(ConfigMerge, AConfiguredControlSocketIsAbsolutizedUnderDaemonize) { ASSERT_TRUE(parse.ok()) << parse.diagnostics(); // Absolutized under -z, which is gated on was_given(). - EXPECT_EQ(parse.options().control_socket.front(), '/'); + EXPECT_TRUE(is_absolute_path(parse.options().control_socket)) << parse.options().control_socket; EXPECT_NE(parse.options().control_socket.find("/sendspin.sock"), std::string::npos) << parse.options().control_socket; } @@ -608,7 +636,7 @@ TEST(ConfigMerge, AConfiguredStateDirIsAbsolutizedUnderDaemonize) { Parse parse({"-z"}, config); ASSERT_TRUE(parse.ok()) << parse.diagnostics(); - EXPECT_EQ(parse.options().state_dir.front(), '/'); + EXPECT_TRUE(is_absolute_path(parse.options().state_dir)) << parse.options().state_dir; } // What a broken config must not be able to stop diff --git a/tests/control_test.cpp b/tests/control_test.cpp index 8cc246a..e3c1842 100644 --- a/tests/control_test.cpp +++ b/tests/control_test.cpp @@ -16,9 +16,15 @@ #include "control.h" +#include "file_access.h" +#include "posix_compat.h" + #include -#include -#include + +#ifdef _WIN32 +#include +#include +#endif #include #include @@ -1166,10 +1172,19 @@ class ScratchDir { this->path_ = "control-test-" + std::to_string(::getpid()) + "-" + std::to_string(ScratchDir::next_id()); this->created_ = ::mkdir(this->path_.c_str(), mode) == 0; +#ifdef _WIN32 + // A mode's group or other bits become a grant to Everyone. + const std::string owner = "(A;OICI;FA;;;" + current_user_sid() + ")"; + if (this->created_) { + this->created_ = set_dacl( + this->path_, "D:P" + owner + ((mode & 077) != 0 ? "(A;OICI;FA;;;WD)" : "")); + } +#else // mkdir() applies the umask, so set the mode again. if (this->created_) { this->created_ = ::chmod(this->path_.c_str(), mode) == 0; } +#endif } ~ScratchDir() { @@ -1222,8 +1237,15 @@ TEST(PrivateRuntimeDir, AGroupOrWorldWritableDirectoryIsRefused) { } TEST(PrivateRuntimeDir, SomethingThatIsNotADirectoryIsRefused) { +#ifdef _WIN32 + const char* root = std::getenv("SystemRoot"); + ASSERT_NE(root, nullptr); + const std::string file = std::string(root) + "/System32/drivers/etc/hosts"; +#else + const std::string file = "/etc/hosts"; +#endif std::string reason; - EXPECT_FALSE(is_private_runtime_dir("/etc/hosts", reason)); + EXPECT_FALSE(is_private_runtime_dir(file, reason)); EXPECT_NE(reason.find("not a directory"), std::string::npos) << reason; } @@ -1234,16 +1256,21 @@ TEST(PrivateRuntimeDir, AMissingPathIsRefused) { } TEST(PrivateRuntimeDir, ADirectoryOwnedBySomeoneElseIsRefused) { +#ifndef _WIN32 // Root owns / and would pass. if (::geteuid() == 0) { GTEST_SKIP() << "running as root, which owns /"; } +#endif std::string reason; EXPECT_FALSE(is_private_runtime_dir("/", reason)); EXPECT_NE(reason.find("not by this user"), std::string::npos) << reason; } TEST(PrivateRuntimeDir, ASymlinkIsJudgedByWhatItPointsAt) { +#ifdef _WIN32 + GTEST_SKIP() << "creating a symlink on Windows needs Developer Mode or an elevated process"; +#else // stat() follows links: a link to a world-writable directory is refused. const ScratchDir target(0700); ASSERT_TRUE(target.created()); @@ -1259,20 +1286,29 @@ TEST(PrivateRuntimeDir, ASymlinkIsJudgedByWhatItPointsAt) { ::unlink(safe_link.c_str()); ::unlink(unsafe_link.c_str()); +#endif } TEST(PrivateRuntimeDir, TmpIsRefused) { std::string reason; +#ifdef _WIN32 + // The shared folder every local user can write: Windows' /tmp. + const char* shared = std::getenv("PUBLIC"); + ASSERT_NE(shared, nullptr); + EXPECT_FALSE(is_private_runtime_dir(shared, reason)) << shared << " must never be usable"; +#else EXPECT_FALSE(is_private_runtime_dir("/tmp", reason)) << "/tmp must never be usable"; +#endif } TEST(PlatformRuntimeDir, WhateverItReturnsIsPrivate) { // Empty except on macOS, and never a path that would fail the check. std::string rejection; const std::string platform = control_platform_runtime_dir(rejection); -#ifdef __APPLE__ +#if defined(__APPLE__) || defined(_WIN32) ASSERT_FALSE(platform.empty()) - << "macOS must supply a per-user runtime directory; rejected because: " << rejection; + << "macOS and Windows must supply a per-user runtime directory; rejected because: " + << rejection; #else // No third source elsewhere. EXPECT_TRUE(platform.empty()) << platform; @@ -1284,8 +1320,12 @@ TEST(PlatformRuntimeDir, WhateverItReturnsIsPrivate) { std::string reason; EXPECT_TRUE(is_private_runtime_dir(platform, reason)) << platform << ": " << reason; // Absolute, and not the one directory that is always wrong. +#ifdef _WIN32 + EXPECT_TRUE(std::filesystem::path(platform).is_absolute()) << platform; +#else EXPECT_EQ(platform.front(), '/') << platform; EXPECT_NE(platform, "/tmp"); +#endif // No trailing slash: confstr() supplies one, and a path built on it would carry a '//'. EXPECT_NE(platform.back(), '/') << platform; // And short enough to still leave room for the socket's own leaf. diff --git a/tests/daemon_test.cpp b/tests/daemon_test.cpp index bd82674..6b9e1c1 100644 --- a/tests/daemon_test.cpp +++ b/tests/daemon_test.cpp @@ -18,11 +18,10 @@ #include "daemon.h" #include "log.h" +#include "posix_compat.h" #include #include -#include -#include #include #include @@ -230,7 +229,8 @@ TEST(LogFileTest, StampsOnlyAFileAndReopensThePathOnSighup) { ASSERT_GE(saved_stderr, 0); // 1. Bare stderr: level letter and tag, no timestamp. - const int scratch = ::open(dir.file("other").c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + const int scratch = + ::open(dir.file("other").c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, 0600); ASSERT_GE(scratch, 0); ASSERT_GE(::dup2(scratch, STDERR_FILENO), 0); ::close(scratch); @@ -250,11 +250,13 @@ TEST(LogFileTest, StampsOnlyAFileAndReopensThePathOnSighup) { const bool opened = log_to_file(dir.file("log")); log_line(sendspin::LogLevel::INFO, LOG_TAG_MDNS, "stamped %d", 2); +#ifndef _WIN32 // 5. Rotation: rename, then the reopen the SIGHUP handler requests. const bool renamed = std::rename(dir.file("log").c_str(), dir.file("log.1").c_str()) == 0; log_handle_sighup(SIGHUP); log_reopen_if_requested(); log_line(sendspin::LogLevel::INFO, LOG_TAG_MDNS, "after rotation %d", 3); +#endif std::fflush(stderr); ASSERT_GE(::dup2(saved_stderr, STDERR_FILENO), 0); @@ -262,7 +264,9 @@ TEST(LogFileTest, StampsOnlyAFileAndReopensThePathOnSighup) { EXPECT_TRUE(opened); EXPECT_FALSE(unopenable); +#ifndef _WIN32 ASSERT_TRUE(renamed); +#endif // Bare stderr lines, including the complaint about the unopenable path. const std::string bare = read_file(dir.file("other")); @@ -271,19 +275,26 @@ TEST(LogFileTest, StampsOnlyAFileAndReopensThePathOnSighup) { EXPECT_NE(bare.find("E cli: fatal at none 9\n"), std::string::npos) << bare; EXPECT_EQ(bare.find("gated away"), std::string::npos) << bare; +#ifdef _WIN32 + // No SIGHUP, so no rotation: the stamped line stays in the file it was written to. + const std::string rotated = read_file(dir.file("log")); +#else const std::string rotated = read_file(dir.file("log.1")); +#endif EXPECT_NE(rotated.find(" I mdns: stamped 2\n"), std::string::npos) << rotated; // 2026-08-10T03:14:15Z, so the letter is at a fixed offset and the Z is the field's end. ASSERT_GE(rotated.size(), 21U); EXPECT_EQ(rotated[10], 'T'); EXPECT_EQ(rotated[19], 'Z'); EXPECT_EQ(rotated[20], ' '); +#ifndef _WIN32 // The old descriptor must no longer be written. EXPECT_EQ(rotated.find("after rotation"), std::string::npos) << rotated; const std::string fresh = read_file(dir.file("log")); EXPECT_NE(fresh.find("Reopened"), std::string::npos) << fresh; EXPECT_NE(fresh.find(" I mdns: after rotation 3\n"), std::string::npos) << fresh; +#endif } } // namespace diff --git a/tests/file_access.h b/tests/file_access.h new file mode 100644 index 0000000..9a99d82 --- /dev/null +++ b/tests/file_access.h @@ -0,0 +1,111 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Who may write a directory, and whether a file is private: modes on POSIX, ACLs on Windows. + +#pragma once + +#include + +#ifdef _WIN32 +// clang-format off +#include +#include +#include +// clang-format on + +#include +#else +#include +#endif + +namespace sendspin_cli { + +#ifdef _WIN32 +/// This user's SID as S-1-..., or empty. +inline std::string current_user_sid() { + HANDLE token = nullptr; + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token) == 0) { + return {}; + } + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector buffer(size); + std::string text; + char* sid = nullptr; + if (size != 0 && GetTokenInformation(token, TokenUser, buffer.data(), size, &size) != 0 && + ConvertSidToStringSidA(reinterpret_cast(buffer.data())->User.Sid, &sid) != 0) { + text = sid; + LocalFree(sid); + } + CloseHandle(token); + return text; +} + +/// Replaces `path`'s DACL with the one `sddl` describes. +inline bool set_dacl(const std::string& path, const std::string& sddl) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (ConvertStringSecurityDescriptorToSecurityDescriptorA(sddl.c_str(), SDDL_REVISION_1, + &descriptor, nullptr) == 0) { + return false; + } + BOOL present = FALSE; + BOOL defaulted = FALSE; + PACL dacl = nullptr; + const bool ok = + GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted) != 0 && + SetNamedSecurityInfoA(const_cast(path.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, nullptr, dacl, nullptr) == ERROR_SUCCESS; + LocalFree(descriptor); + return ok; +} +#endif + +/// Lets this user create files in `path`, or stops them: the permissions case. +inline bool set_writable(const std::string& path, bool writable) { +#ifdef _WIN32 + const std::string sid = current_user_sid(); + // Denying add-file, add-subdirectory and delete-child is what 0500 does to a directory. + return !sid.empty() && + set_dacl(path, writable ? "D:P(A;OICI;FA;;;" + sid + ")" + : "D:P(D;;0x46;;;" + sid + ")(A;OICI;FA;;;" + sid + ")"); +#else + return ::chmod(path.c_str(), writable ? 0700 : 0500) == 0; +#endif +} + +#ifdef _WIN32 +/// True if `path`'s ACL grants this user, and nobody else, any access: its 0600. +inline bool owner_only(const std::string& path, std::string& detail) { + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (GetNamedSecurityInfoA(path.c_str(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, nullptr, + nullptr, &dacl, nullptr, &descriptor) != ERROR_SUCCESS) { + detail = "cannot read the ACL"; + return false; + } + char* text = nullptr; + if (ConvertSecurityDescriptorToStringSecurityDescriptorA( + descriptor, SDDL_REVISION_1, DACL_SECURITY_INFORMATION, &text, nullptr) != 0) { + detail = text; + LocalFree(text); + } + LocalFree(descriptor); + // Protected, and one allow ACE for this user. + return detail == "D:P(A;;FA;;;" + current_user_sid() + ")"; +} +#endif + +} // namespace sendspin_cli diff --git a/tests/hooks_test.cpp b/tests/hooks_test.cpp index bad7557..7e97751 100644 --- a/tests/hooks_test.cpp +++ b/tests/hooks_test.cpp @@ -18,10 +18,14 @@ #include "cli.h" #include "parse_harness.h" +#include "posix_compat.h" #include "scoped_env.h" #include -#include + +#ifdef _WIN32 +#include +#endif #include #include @@ -162,17 +166,31 @@ TEST(HookRunner, RunsTheCommandWithTheEventEnvironment) { context.client_name = "kitchen"; HookRunner runner; +#ifdef _WIN32 + runner.run(" " + + out.path(), + "start", context); +#else runner.run("printf '%s|%s|%s|%s|%s|%s' \"$SENDSPIN_EVENT\" \"$SENDSPIN_SERVER_ID\" " "\"$SENDSPIN_SERVER_NAME\" \"$SENDSPIN_SERVER_URL\" \"$SENDSPIN_CLIENT_ID\" " "\"$SENDSPIN_CLIENT_NAME\" > " + out.path(), "start", context); +#endif ASSERT_TRUE(drain(runner)); EXPECT_EQ(slurp(out.path()), "start|srv-1|Living Room|ws://hifi:8927/sendspin|kitchen-left|kitchen"); } +#ifdef _WIN32 +/// A command printing `name`'s value, or "unset" when the hook did not get it, with no newline. +std::string value_or_unset(const std::string& name) { + return "(if defined " + name + " ( " + out.path(), + "stop", context); +#else runner.run( "printf '%s|%s' \"${SENDSPIN_SERVER_ID-unset}\" \"${SENDSPIN_SERVER_URL-unset}\" > " + out.path(), "stop", context); +#endif ASSERT_TRUE(drain(runner)); EXPECT_EQ(slurp(out.path()), "unset|unset"); @@ -194,8 +218,12 @@ TEST(HookRunner, AnInheritedSendspinVariableDoesNotLeakThrough) { ScratchFile out; HookRunner runner; +#ifdef _WIN32 + runner.run(value_or_unset("SENDSPIN_SERVER_ID") + "> " + out.path(), "start", HookContext{}); +#else runner.run("printf '%s' \"${SENDSPIN_SERVER_ID-unset}\" > " + out.path(), "start", HookContext{}); +#endif ASSERT_TRUE(drain(runner)); EXPECT_EQ(slurp(out.path()), "unset"); @@ -212,9 +240,34 @@ TEST(HookRunner, AFailingHookIsReapedRatherThanLeaked) { // One at a time, newest event wins the wait +/// A command appending `text` to `path` with no newline, which cmd's echo cannot leave off. +std::string append_text(const std::string& path, const std::string& text) { +#ifdef _WIN32 + return "> " + path; +#else + return "printf '" + text + "' >> " + path; +#endif +} + +/// A command that succeeds and does nothing. +std::string succeed() { +#ifdef _WIN32 + return "exit /b 0"; +#else + return "true"; +#endif +} + /// A hook that waits for `gate` to exist, then runs `then`: ordering without timing assumptions. std::string gated(const std::string& gate, const std::string& then) { +#ifdef _WIN32 + // cmd has neither a short sleep nor a loop it can leave. + return "powershell -NoProfile -NonInteractive -Command \"while (-not (Test-Path -LiteralPath " + "'" + + gate + "')) { Start-Sleep -Milliseconds 10 }\" & " + then; +#else return "while [ ! -e " + gate + " ]; do sleep 0.01; done; " + then; +#endif } void open_gate(const std::string& path) { @@ -226,8 +279,8 @@ TEST(HookRunner, ASecondEventWaitsForTheRunningHook) { ScratchFile gate; HookRunner runner; // Run side by side, the stop hook's 'b' would land first; the order below is the contract. - runner.run(gated(gate.path(), "printf 'a' >> " + out.path()), "start", HookContext{}); - runner.run("printf 'b' >> " + out.path(), "stop", HookContext{}); + runner.run(gated(gate.path(), append_text(out.path(), "a")), "start", HookContext{}); + runner.run(append_text(out.path(), "b"), "stop", HookContext{}); EXPECT_EQ(runner.running(), 1U); open_gate(gate.path()); @@ -239,9 +292,9 @@ TEST(HookRunner, TheNewestEventReplacesTheWaitingOne) { ScratchFile out; ScratchFile gate; HookRunner runner; - runner.run(gated(gate.path(), "printf 'a' >> " + out.path()), "start", HookContext{}); - runner.run("printf 'b' >> " + out.path(), "stop", HookContext{}); - runner.run("printf 'c' >> " + out.path(), "start", HookContext{}); + runner.run(gated(gate.path(), append_text(out.path(), "a")), "start", HookContext{}); + runner.run(append_text(out.path(), "b"), "stop", HookContext{}); + runner.run(append_text(out.path(), "c"), "start", HookContext{}); open_gate(gate.path()); ASSERT_TRUE(drain(runner)); @@ -252,11 +305,15 @@ TEST(HookRunner, AWaitingEventKeepsItsOwnContext) { ScratchFile out; ScratchFile gate; HookRunner runner; - runner.run(gated(gate.path(), "true"), "start", HookContext{}); + runner.run(gated(gate.path(), succeed()), "start", HookContext{}); HookContext context; context.server_id = "srv-b"; +#ifdef _WIN32 + runner.run(" " + out.path(), "stop", context); +#else runner.run("printf '%s' \"$SENDSPIN_SERVER_ID\" > " + out.path(), "stop", context); +#endif // The waiting event keeps the context it was fired with. context.server_id = "srv-c"; @@ -269,8 +326,8 @@ TEST(HookRunner, FlushRunsTheWaitingHookBesideAHungOne) { ScratchFile out; ScratchFile gate; HookRunner runner; - runner.run(gated(gate.path(), "true"), "start", HookContext{}); - runner.run("printf 'b' >> " + out.path(), "stop", HookContext{}); + runner.run(gated(gate.path(), succeed()), "start", HookContext{}); + runner.run(append_text(out.path(), "b"), "stop", HookContext{}); // flush() runs both at once: the stop hook outranks ordering at shutdown. runner.flush(); @@ -294,6 +351,7 @@ TEST(HookRunner, FlushWithNothingWaitingDoesNothing) { EXPECT_EQ(runner.running(), 0U); } +#ifndef _WIN32 TEST(HookRunner, DoesNotHandTheHookThePlayersIgnoredSIGPIPE) { // An ignored SIGPIPE survives execve(); 141 (128 + SIGPIPE) shows the child reset it. ScopedSignal ignored(SIGPIPE, SIG_IGN); @@ -330,6 +388,33 @@ TEST(HookRunner, DoesNotHandTheHookThePlayersOpenDescriptors) { ASSERT_TRUE(drained); EXPECT_EQ(slurp(out.path()), "closed\n"); } +#else +TEST(HookRunner, DoesNotHandTheHookThePlayersOpenHandles) { + // An inheritable, unshared handle stands in for the player's own sockets. + ScratchFile held_path; + ScratchFile gate; + SECURITY_ATTRIBUTES inherit = {sizeof(inherit), nullptr, TRUE}; + HANDLE held = CreateFileA(held_path.path().c_str(), GENERIC_WRITE, 0, &inherit, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + ASSERT_NE(held, INVALID_HANDLE_VALUE); + + HookRunner runner; + runner.run(gated(gate.path(), succeed()), "start", HookContext{}); + CloseHandle(held); + + // A copy inherited by the hook would still hold the file open and unshared. + HANDLE reopened = CreateFileA(held_path.path().c_str(), GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + const bool free = reopened != INVALID_HANDLE_VALUE; + if (free) { + CloseHandle(reopened); + } + + open_gate(gate.path()); + ASSERT_TRUE(drain(runner)); + EXPECT_TRUE(free); +} +#endif } // namespace } // namespace sendspin_cli diff --git a/tests/parse_harness.h b/tests/parse_harness.h index e5d31bc..069de21 100644 --- a/tests/parse_harness.h +++ b/tests/parse_harness.h @@ -21,19 +21,38 @@ #include #include +#ifdef _WIN32 +#include +#endif #include #include #include namespace sendspin_cli { +/// A config file that exists and is empty. +#ifdef _WIN32 +inline constexpr const char* EMPTY_CONFIG = "NUL"; +#else +inline constexpr const char* EMPTY_CONFIG = "/dev/null"; +#endif + +/// Whether `path` names the same file from any working directory. +inline bool is_absolute_path(const std::string& path) { +#ifdef _WIN32 + return std::filesystem::path(path).is_absolute(); +#else + return !path.empty() && path.front() == '/'; +#endif +} + /// Runs parse_options() on a list of words, owning argv and a tmpfile() diagnostics stream. -/// Always names a config (default /dev/null) so no machine's real config leaks into a test. +/// Always names a config (default EMPTY_CONFIG) so no machine's real config leaks into a test. class Parse { public: /// @param args The command line after argv[0]. A subcommand must still come first. /// @param config The config file to read; defaults to an empty one. - explicit Parse(std::vector args, std::string config = "/dev/null") + explicit Parse(std::vector args, std::string config = EMPTY_CONFIG) : words_(std::move(args)) { this->words_.insert(this->words_.begin(), "sendspin-cli"); // Inserted after any subcommand, and before flags so a missing value cannot swallow it. diff --git a/tests/player_listener_test.cpp b/tests/player_listener_test.cpp index ff112ee..64b9c39 100644 --- a/tests/player_listener_test.cpp +++ b/tests/player_listener_test.cpp @@ -17,13 +17,12 @@ #include "player_listener.h" #include "null_sink.h" +#include "posix_compat.h" #include "state_store.h" #include #include -#include -#include #include #include diff --git a/tests/posix_compat.h b/tests/posix_compat.h new file mode 100644 index 0000000..af7073f --- /dev/null +++ b/tests/posix_compat.h @@ -0,0 +1,39 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// The POSIX file calls the tests' fixtures make, on Windows too. + +#pragma once + +#ifdef _WIN32 +#include +#include +#include + +/// Windows directories take their ACL from the parent, not a mode. +inline int mkdir(const char* path, unsigned /*mode*/) { + return ::_mkdir(path); +} + +#define STDERR_FILENO 2 +#define F_OK 0 + +/// For fixtures that take a mode; file_access.h turns one into an ACL. +using mode_t = unsigned; +#else +#include +#include +// Only Windows translates line endings. +#define O_BINARY 0 +#endif diff --git a/tests/scoped_env.h b/tests/scoped_env.h index da7f5ae..92b7389 100644 --- a/tests/scoped_env.h +++ b/tests/scoped_env.h @@ -32,17 +32,17 @@ class ScopedEnv { this->previous_ = previous; } if (value == nullptr) { - ::unsetenv(name); + unset(name); } else { - ::setenv(name, value, 1); + set(name, value); } } ~ScopedEnv() { if (this->had_previous_) { - ::setenv(this->name_, this->previous_.c_str(), 1); + set(this->name_, this->previous_.c_str()); } else { - ::unsetenv(this->name_); + unset(this->name_); } } @@ -50,6 +50,23 @@ class ScopedEnv { ScopedEnv& operator=(const ScopedEnv&) = delete; private: +#ifdef _WIN32 + // Windows holds no empty variable: "" unsets it, which every reader here treats alike. + static void set(const char* name, const char* value) { + ::_putenv_s(name, value); + } + static void unset(const char* name) { + ::_putenv_s(name, ""); + } +#else + static void set(const char* name, const char* value) { + ::setenv(name, value, 1); + } + static void unset(const char* name) { + ::unsetenv(name); + } +#endif + const char* name_; std::string previous_; bool had_previous_{false}; diff --git a/tests/state_store_test.cpp b/tests/state_store_test.cpp index db467e5..a2d5170 100644 --- a/tests/state_store_test.cpp +++ b/tests/state_store_test.cpp @@ -16,13 +16,18 @@ #include "state_store.h" +#include "file_access.h" #include "key_value_file.h" +#include "posix_compat.h" #include "scoped_env.h" #include "sendspin/persistence_keys.h" +#ifdef _WIN32 +#include "win_security.h" +#endif + #include #include -#include #include #include @@ -44,7 +49,7 @@ class ScratchDir { ~ScratchDir() { // Restored in case a test made it unwritable, or the removals below cannot work. - ::chmod(this->path_.c_str(), 0700); + set_writable(this->path_, true); // A failed write can leave its temporary behind, and the pid is in that name. const std::string temporary_suffix = ".tmp." + std::to_string(getpid()); for (const std::string& leaf : {std::string("state"), "state" + temporary_suffix}) { @@ -196,9 +201,14 @@ TEST(StateStore, WritesTheFileAt0600) { StateStore store(scratch.file()); ASSERT_TRUE(store.set_last_server("srv-abc123")); +#ifdef _WIN32 + std::string acl; + EXPECT_TRUE(owner_only(scratch.file(), acl)) << acl; +#else struct stat info = {}; ASSERT_EQ(::stat(scratch.file().c_str(), &info), 0); EXPECT_EQ(info.st_mode & 0777, 0600U); +#endif } TEST(StateStore, LeavesNoTemporaryBehind) { @@ -259,7 +269,7 @@ TEST(StateStore, AnUnwritableDirectoryFails) { const ScratchDir scratch; ASSERT_TRUE(scratch.created()); // Walkable but not writable: the permissions case. - ASSERT_EQ(::chmod(scratch.path().c_str(), 0500), 0); + ASSERT_TRUE(set_writable(scratch.path(), false)); StateStore store(scratch.file()); EXPECT_FALSE(store.set_last_server("srv-abc123")); @@ -270,14 +280,14 @@ TEST(StateStore, AFailedWriteIsNotRememberedAsASuccess) { ASSERT_TRUE(scratch.created()); StateStore store(scratch.file()); ASSERT_TRUE(store.set_volume_and_muted(30, false)); - ASSERT_EQ(::chmod(scratch.path().c_str(), 0500), 0); + ASSERT_TRUE(set_writable(scratch.path(), false)); EXPECT_FALSE(store.set_volume_and_muted(40, false)); // Rolled back, or the short-circuit would claim the retry below succeeded. EXPECT_EQ(store.volume(), 30); EXPECT_FALSE(store.set_volume_and_muted(40, false)); - ASSERT_EQ(::chmod(scratch.path().c_str(), 0700), 0); + ASSERT_TRUE(set_writable(scratch.path(), true)); EXPECT_TRUE(store.set_volume_and_muted(40, false)); EXPECT_EQ(loaded(scratch.file()).volume(), 40); } @@ -304,9 +314,9 @@ TEST(StateStore, WritesVolumeAndMuteInOneGo) { ASSERT_TRUE(store.set_volume_and_muted(20, false)); // One write for the pair: with writing impossible, neither half may move. - ASSERT_EQ(::chmod(scratch.path().c_str(), 0500), 0); + ASSERT_TRUE(set_writable(scratch.path(), false)); EXPECT_FALSE(store.set_volume_and_muted(90, true)); - ASSERT_EQ(::chmod(scratch.path().c_str(), 0700), 0); + ASSERT_TRUE(set_writable(scratch.path(), true)); const StateStore reloaded = loaded(scratch.file()); EXPECT_EQ(reloaded.volume(), 20); @@ -347,9 +357,14 @@ TEST(StateStore, ABlobWriteLeavesTheFileAt0600) { const std::vector key(32, 0x5a); ASSERT_TRUE(store.set_blob(sendspin::persistence_keys::KEYPAIR, key.data(), key.size())); +#ifdef _WIN32 + std::string acl; + EXPECT_TRUE(owner_only(scratch.file(), acl)) << acl; +#else struct stat info{}; ASSERT_EQ(::stat(scratch.file().c_str(), &info), 0); EXPECT_EQ(info.st_mode & 0777, 0600U); +#endif } TEST(StateStore, APairingRecordIsAnOccupiedSlotNotAFreedOne) { @@ -472,6 +487,20 @@ TEST(StateStorePath, PrefersXdgStateHome) { EXPECT_EQ(state_store_path(""), "/xdg/state/sendspin-cli/state"); } +#ifdef _WIN32 +TEST(StateStorePath, FallsBackToLocalAppDataRatherThanHome) { + const ScopedEnv state("XDG_STATE_HOME", nullptr); + const ScopedEnv home("HOME", "/home/someone"); + + EXPECT_EQ(state_store_path(""), local_app_data_dir() + "/sendspin-cli/state"); +} + +TEST(StateStorePath, AnEmptyXdgStateHomeCountsAsUnset) { + const ScopedEnv state("XDG_STATE_HOME", ""); + + EXPECT_EQ(state_store_path(""), local_app_data_dir() + "/sendspin-cli/state"); +} +#else TEST(StateStorePath, FallsBackToHomeLocalState) { const ScopedEnv state("XDG_STATE_HOME", nullptr); const ScopedEnv home("HOME", "/home/someone"); @@ -492,6 +521,7 @@ TEST(StateStorePath, IsEmptyWhenThereIsNowhereToPutIt) { EXPECT_TRUE(state_store_path("").empty()); } +#endif // The shared flat file format From caf852fe76f9928cd6bc409e2cd1d910846038ff Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:58:07 -0500 Subject: [PATCH 09/15] Build and test on Windows in CI A windows-x86_64 leg: clang-cl from the image's Visual Studio, set up from vcvars64.bat inline rather than through a third-party action, configured with Ninja and -DSENDSPIN_CLI_WERROR=ON like every leg. It asserts the backends (null, stdout, portaudio) and windns mDNS, and runs the test suite. It publishes nothing yet: no smoke test, package or installer. --- .github/workflows/build.yml | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0f3781..a74351a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -106,6 +106,23 @@ jobs: avahi: false systemd: true + # clang-cl from the image's Visual Studio; PortAudio is built from source and DNS-SD is + # part of Windows, so nothing is installed. No probes: there are no Bonjour enumerators. + - name: windows-x86_64 + runner: windows-2025 + arm32: '' + with_mdns: 'ON' + with_portaudio: 'ON' + with_pulse: 'OFF' + with_pipewire: 'OFF' + expect_probe: '' + expect_backends: '^-- sendspin-cli audio backends: null, stdout, portaudio$' + expect_mdns: '^-- sendspin-cli mDNS: windns \(built in\)$' + runtime_packages: 'none. PortAudio is linked in, and DNS-SD is part of Windows 10 1809 and later.' + publish: false + avahi: false + systemd: false + steps: # Actions pinned to commits; the trailing comment is the version. - name: Check out @@ -124,6 +141,37 @@ jobs: libpipewire-0.3-dev \ libavahi-compat-libdnssd-dev + # What vcvars64.bat changes, exported for the later steps; inline rather than a third-party action. + - name: Set up clang-cl and the MSVC environment (Windows) + if: runner.os == 'Windows' + run: | + vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe' + vs=$("$vswhere" -latest -products '*' \ + -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath) + [ -n "$vs" ] || { + echo '::error::this image has no Visual Studio with the clang-cl component' + exit 1 + } + echo "Visual Studio: $vs" + + printf '@call "%s\\VC\\Auxiliary\\Build\\vcvars64.bat" >nul\r\n@set\r\n' "$vs" >vcvars.bat + cmd //c set | tr -d '\r' | sort >before.txt + # A full path: cmd may not look in the current directory for it. + cmd //c "$(cygpath -w "$PWD/vcvars.bat")" | tr -d '\r' | sort >after.txt + grep -qi '^VCToolsInstallDir=' after.txt || { + echo '::error::vcvars64.bat did not set up the developer environment' + exit 1 + } + + # New or changed variables; PATH goes through GITHUB_PATH instead. + comm -13 before.txt after.txt | grep -vi '^path=' >>"$GITHUB_ENV" + before_path=$(grep -i '^path=' before.txt | cut -d= -f2- | tr ';' '\n') + grep -i '^path=' after.txt | cut -d= -f2- | tr ';' '\n' | + grep -vxF -f <(printf '%s\n' "$before_path") >>"$GITHUB_PATH" || true + # The developer environment does not put clang-cl on PATH. + printf '%s\n' "$vs\\VC\\Tools\\Llvm\\x64\\bin" >>"$GITHUB_PATH" + rm -f vcvars.bat before.txt after.txt + # Instead of the step above, so no host-arch -dev library can answer find_library. - name: Install the cross toolchain and armhf dependencies (cross-compiled 32-bit ARM) if: matrix.arm32 @@ -233,6 +281,9 @@ jobs: -DSENDSPIN_CLI_WITH_PIPEWIRE="$WITH_PIPEWIRE" -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" ) + if [ "$RUNNER_OS" = 'Windows' ]; then + options+=(-G Ninja -DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl) + fi if [ -n "$ARM32" ]; then scripts/build_arm32.sh "$ARM32" build "${options[@]}" 2>&1 | tee configure.log From b815fcbefa8ba3877c200836ef285f476920c4c7 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 14:58:07 -0500 Subject: [PATCH 10/15] Pin sendspin-cpp to the head of sendspin-cpp#211 for now The pinned 90cbd808 predates Windows support in the library (#210), and #211 carries the Winsock initialization the player needs there. Re-pin to #211's merge commit before this lands. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 19703e3..9189827 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,7 +31,7 @@ endif() # sendspin-cpp -set(SENDSPIN_GIT_TAG "90cbd80864bf763fb6a4100f23b1ad2e128c8a75" CACHE STRING +set(SENDSPIN_GIT_TAG "763be9cc536d5a333ffb2fede48c79978575c077" CACHE STRING "sendspin-cpp git tag or commit to build against") # player, metadata, controller (group transport verbs) and source; no FORCE, so -D overrides still win. From 9911b9bf58801391d343c46472b7d03df822dd4c Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 15:39:33 -0500 Subject: [PATCH 11/15] Publish a Windows zip in releases - scripts/package_windows.ps1 packages the binary, its docs and BUILD-INFO.txt as one folder in a zip, asserted to hold exactly that. Entries are written with '/' separators: Windows PowerShell's CreateFromDirectory() writes '\' - scripts/smoke_test_windows.ps1 covers smoke_test.sh's ground on Windows, where its kill/wait handling does not apply: --version, a status round trip over the control socket, a clean stop on taskkill, and -z with its pidfile - The windows-x86_64 leg publishes: it runs the Windows smoke test, packages the zip and uploads it, in place of the tarball steps - release.yml expects the zip in both asset checks, checksums it into SHA256SUMS, uploads it, and says in the notes that it is unsigned --- .github/workflows/build.yml | 37 ++++++++-- .github/workflows/release.yml | 14 +++- scripts/package_windows.ps1 | 131 +++++++++++++++++++++++++++++++++ scripts/smoke_test_windows.ps1 | 129 ++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 scripts/package_windows.ps1 create mode 100644 scripts/smoke_test_windows.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a74351a..d2ce704 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,8 @@ jobs: # Every leg's result, not just the first failure. fail-fast: false # Spell every key on every leg, false included: a missing key reads as false and silently disables that leg's work. - # A publish: true leg also needs entries in both asset sets release.yml diffs (macOS: tarball and .pkg). + # A publish: true leg also needs entries in both asset sets release.yml diffs (macOS: tarball and .pkg; + # Windows: zip). matrix: include: - name: linux-x86_64 @@ -119,7 +120,7 @@ jobs: expect_backends: '^-- sendspin-cli audio backends: null, stdout, portaudio$' expect_mdns: '^-- sendspin-cli mDNS: windns \(built in\)$' runtime_packages: 'none. PortAudio is linked in, and DNS-SD is part of Windows 10 1809 and later.' - publish: false + publish: true avahi: false systemd: false @@ -382,9 +383,15 @@ jobs: # Before avahi-daemon is installed, to exercise the non-fatal advertise-failure path. - name: Smoke test - if: matrix.publish + if: matrix.publish && runner.os != 'Windows' run: scripts/smoke_test.sh build/sendspin-cli + # smoke_test.sh's kill/wait handling does not apply to a Windows binary; the same ground in PowerShell. + - name: Smoke test (Windows) + if: matrix.publish && runner.os == 'Windows' + shell: pwsh + run: scripts/smoke_test_windows.ps1 -Binary build/sendspin-cli.exe + # Proves libavahi-compat-libdnssd implements our dns_sd calls: register, browse, resolve, QueryRecord. - name: Advertise and discover through a real Avahi daemon if: matrix.avahi @@ -464,7 +471,7 @@ jobs: # DESTDIR (not --prefix) keeps ExecStart valid; --component keeps it ours; tarred to keep +x. - name: Package - if: matrix.publish + if: matrix.publish && runner.os != 'Windows' id: package env: RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} @@ -566,7 +573,7 @@ jobs: # Assert the exact payload; keep in step with build-armv6.yml's list of the same. - name: Assert the payload holds exactly what it should - if: matrix.publish + if: matrix.publish && runner.os != 'Windows' env: NAME: ${{ steps.package.outputs.name }} run: | @@ -864,8 +871,17 @@ jobs: /usr/local/bin/sendspin-cli --version scripts/smoke_test.sh /usr/local/bin/sendspin-cli + # A zip of the binary and its docs, asserted to hold exactly that. + - name: Package (Windows) + if: matrix.publish && runner.os == 'Windows' + id: package_windows + shell: pwsh + env: + LEG: ${{ matrix.name }} + run: scripts/package_windows.ps1 -Binary build/sendspin-cli.exe -Leg $env:LEG + - name: Upload - if: matrix.publish + if: matrix.publish && runner.os != 'Windows' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.package.outputs.name }} @@ -874,6 +890,15 @@ jobs: retention-days: 14 if-no-files-found: error + - name: Upload (Windows) + if: matrix.publish && runner.os == 'Windows' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.package_windows.outputs.name }} + path: ${{ steps.package_windows.outputs.name }}.zip + retention-days: 14 + if-no-files-found: error + # A separate step: if-no-files-found: error would fail Linux legs, and artifact names must differ. - name: Upload the macOS installer if: matrix.publish && runner.os == 'macOS' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a840aa..884d059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,13 +154,14 @@ jobs: # Diff the downloaded set against the expected assets: a green build may have uploaded nothing. - name: Assert the release is complete before creating anything run: | - # File names, spelled out; macOS contributes a tarball and a .pkg. + # File names, spelled out; macOS contributes a tarball and a .pkg, Windows a zip. expected=$(mktemp) { for leg in linux-x86_64 linux-arm64 linux-armv7 linux-armv6 macos-arm64; do echo "sendspin-cli-$VERSION-$leg.tar.gz" done echo "sendspin-cli-$VERSION-macos-arm64.pkg" + echo "sendspin-cli-$VERSION-windows-x86_64.zip" } | sort >"$expected" actual=$(mktemp) @@ -177,8 +178,8 @@ jobs: - name: Checksum what is being published run: | cd dist - # The .pkg is checksummed too; an unmatched glob errors under -e, and the set is already asserted. - sha256sum -- *.tar.gz *.pkg >SHA256SUMS + # The .pkg and .zip are checksummed too; an unmatched glob errors under -e, and the set is already asserted. + sha256sum -- *.tar.gz *.pkg *.zip >SHA256SUMS cat SHA256SUMS - name: Write the release notes @@ -215,6 +216,10 @@ jobs: a link because it is worth knowing before you download; what to do about it is under [the macOS installation guide](https://github.com/@REPO@/wiki/Installation#macos). + The Windows zip is **unsigned** as well, so SmartScreen may warn the first time it runs. + [Getting Started on Windows](https://github.com/@REPO@/wiki/Getting-Started-on-Windows) + has a guided installer that checks the zip against `SHA256SUMS` before installing it. + - [README](https://github.com/@REPO@/blob/@TAG@/README.md) — what it is, how to run it, and how to contribute - [Contributors](https://github.com/@REPO@/blob/@TAG@/contributors.md) — development, @@ -244,7 +249,7 @@ jobs: --title "sendspin-cli $VERSION" \ --notes-file notes.md \ --draft \ - dist/*.tar.gz dist/*.pkg dist/SHA256SUMS | tee created.txt + dist/*.tar.gz dist/*.pkg dist/*.zip dist/SHA256SUMS | tee created.txt # Resolve the draft's numeric id from its html_url once; later steps use the REST id endpoints. url="$(tail -n1 created.txt)" @@ -271,6 +276,7 @@ jobs: echo "sendspin-cli-$VERSION-$leg.tar.gz" done echo "sendspin-cli-$VERSION-macos-arm64.pkg" + echo "sendspin-cli-$VERSION-windows-x86_64.zip" } | sort >"$expected" # Read back by id: releases/tags/ never returns drafts, which once failed every release. diff --git a/scripts/package_windows.ps1 b/scripts/package_windows.ps1 new file mode 100644 index 0000000..3e2c976 --- /dev/null +++ b/scripts/package_windows.ps1 @@ -0,0 +1,131 @@ +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Packages a Windows build as the release zip: one folder holding sendspin-cli.exe, its docs +# and BUILD-INFO.txt, asserted to hold exactly that. +# +# Usage: package_windows.ps1 -Binary [-Leg windows-x86_64] [-OutDir .] +# +# Prints the archive's name, and writes name= to $GITHUB_OUTPUT when that is set. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Binary, + [string]$Leg = 'windows-x86_64', + [string]$OutDir = '.' +) + +$ErrorActionPreference = 'Stop' + +function Fail([string]$Message) { + Write-Host "package_windows: FAIL: $Message" + exit 1 +} + +$Root = Split-Path -Parent $PSScriptRoot +$Binary = (Resolve-Path -LiteralPath $Binary).Path +$OutDir = (New-Item -ItemType Directory -Force -Path $OutDir).FullName + +$reported = @(& $Binary --version) +$version = ($reported[0] -split '\s+')[1] +$libTag = ($reported[1] -split '\s+')[1] +if (-not $version -or -not $libTag) { + Fail "'$Binary --version' did not report a version and a library tag: $($reported -join ' / ')" +} +$name = "sendspin-cli-$version-$Leg" + +$stage = Join-Path $OutDir "stage\$name" +if (Test-Path -LiteralPath $stage) { + Fail "$stage already exists; package into an empty directory" +} +New-Item -ItemType Directory -Path $stage | Out-Null + +Copy-Item -LiteralPath $Binary -Destination $stage +foreach ($doc in 'README.md', 'LICENSE', 'contributors.md', 'packaging\sendspin-cli.conf.example') { + Copy-Item -LiteralPath (Join-Path $Root $doc) -Destination $stage +} + +$commit = if ($env:GITHUB_SHA) { $env:GITHUB_SHA } else { 'a local build' } +@" +sendspin-cli $version for $Leg +Built from commit $commit +Linked against sendspin-cpp $libTag + +Runtime packages this binary needs: + none. PortAudio is linked in, and DNS-SD is part of Windows 10 1809 and later. + +The quickest way to install it is the guided installer, which verifies the release, installs +it for your user, and starts it when you log on: + + https://github.com/Sendspin/sendspin-cpp-cli/blob/main/scripts/get_started_windows.ps1 + +Or run it from where you unpacked it: + + .\$name\sendspin-cli.exe -l (the devices it can play through) + .\$name\sendspin-cli.exe (play through the default device) + +This binary is unsigned, so Windows SmartScreen may warn the first time it runs, and a zip +downloaded through a browser marks its files as coming from the internet. Check the zip +against SHA256SUMS first: + + Get-FileHash $name.zip -Algorithm SHA256 + +then unblock what you unpacked: + + Get-ChildItem .\$name | Unblock-File + +A server finds the player over mDNS and then connects to it on port 8928, which Windows +Firewall blocks until a rule allows it. The guided installer offers to add one. +"@ | Set-Content -LiteralPath (Join-Path $stage 'BUILD-INFO.txt') -Encoding ascii + +# Exactly these, so nothing stray ships. +$expected = @('BUILD-INFO.txt', 'LICENSE', 'README.md', 'contributors.md', 'sendspin-cli.conf.example', + 'sendspin-cli.exe') | Sort-Object +$actual = @(Get-ChildItem -LiteralPath $stage -Recurse -File | ForEach-Object { $_.Name }) | Sort-Object +if (Compare-Object $expected $actual) { + Fail "the staged payload holds '$($actual -join ', ')', not '$($expected -join ', ')'" +} + +$zip = Join-Path $OutDir "$name.zip" +Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem +$stream = [IO.File]::Open($zip, [IO.FileMode]::CreateNew) +$writer = New-Object IO.Compression.ZipArchive($stream, [IO.Compression.ZipArchiveMode]::Create) +try { + foreach ($file in Get-ChildItem -LiteralPath $stage -File) { + # Entry names spelled with '/': Windows PowerShell's CreateFromDirectory() writes '\'. + [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($writer, $file.FullName, + "$name/$($file.Name)", [IO.Compression.CompressionLevel]::Optimal) | Out-Null + } +} finally { + $writer.Dispose() + $stream.Dispose() +} + +# Again from the zip itself, which is what ships. +$archive = [IO.Compression.ZipFile]::OpenRead($zip) +try { + $inZip = @($archive.Entries | Where-Object { $_.Name } | ForEach-Object { $_.FullName }) | Sort-Object +} finally { + $archive.Dispose() +} +$wanted = @($expected | ForEach-Object { "$name/$_" }) | Sort-Object +if (Compare-Object $wanted $inZip) { + Fail "the zip holds '$($inZip -join ', ')', not what was staged" +} + +Write-Host $zip +if ($env:GITHUB_OUTPUT) { + "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 +} diff --git a/scripts/smoke_test_windows.ps1 b/scripts/smoke_test_windows.ps1 new file mode 100644 index 0000000..a9dcd3c --- /dev/null +++ b/scripts/smoke_test_windows.ps1 @@ -0,0 +1,129 @@ +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# smoke_test.sh's ground on Windows, where its kill/wait handling does not apply: --version, +# the control socket round trip, a clean stop on taskkill, and -z with its pidfile. +# +# Usage: smoke_test_windows.ps1 -Binary + +[CmdletBinding()] +param([Parameter(Mandatory = $true)][string]$Binary) + +$ErrorActionPreference = 'Stop' +$Binary = (Resolve-Path -LiteralPath $Binary).Path +$Work = Join-Path ([IO.Path]::GetTempPath()) ("sendspin-smoke-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $Work | Out-Null +# Every run names NUL as its config, so no config on the machine leaks in. +$Common = @('--config', 'NUL', '-o', 'null', '--no-mdns') +$failures = 0 + +function Pass([string]$What) { + Write-Host "PASS: $What" +} + +function Check([bool]$Ok, [string]$What, [string]$Detail = '') { + if ($Ok) { + Pass $What + } else { + Write-Host "FAIL: $What$(if ($Detail) { " -- $Detail" })" + $script:failures++ + } +} + +# Until the condition holds or the deadline passes; generous for loaded runners. +function Wait-For([scriptblock]$Condition, [int]$Seconds = 20) { + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + if (& $Condition) { + return $true + } + Start-Sleep -Milliseconds 100 + } + return [bool](& $Condition) +} + +# A native command's output with its stderr merged in, as strings. Windows PowerShell turns +# stderr into error records, which the script's 'Stop' preference would throw on. +function Invoke-Native([scriptblock]$Command) { + $ErrorActionPreference = 'Continue' + & $Command 2>&1 | ForEach-Object { "$_" } +} + +function Read-Log([string]$Path) { + if (Test-Path -LiteralPath $Path) { + return (Get-Content -LiteralPath $Path -Raw) + } + return '' +} + +try { + # Basics + $version = @(& $Binary --version) + Check ($LASTEXITCODE -eq 0 -and $version[0] -match '^sendspin-cli \S+$' -and $version[1] -match '^sendspin-cpp \S+$') ` + '--version names this player and the library it links' ($version -join ' / ') + & $Binary --help @Common | Out-Null + Check ($LASTEXITCODE -eq 0) '--help exits 0' + + # A foreground player, its control socket, and a clean stop. Its own hidden console, so + # taskkill without /F delivers the close event. + $log = Join-Path $Work 'foreground.log' + $port = 39311 + $player = Start-Process -FilePath $Binary -PassThru -WindowStyle Hidden -ArgumentList (@( + '--port', $port, '-f', "`"$log`"", '--state-dir', "`"$Work\state`"") + $Common) + Check (Wait-For { (Read-Log $log) -match "listening on port $port" }) 'the player starts and listens' (Read-Log $log) + + $status = @(Invoke-Native { & $Binary status --port $port --config NUL }) + Check ($LASTEXITCODE -eq 0 -and ($status -join "`n") -match '(?m)^name: ') ` + 'status answers over the control socket' ($status -join ' / ') + Invoke-Native { & $Binary pause --port $port --config NUL } | Out-Null + Check ($LASTEXITCODE -eq 4) 'pause with no server is refused as not connected (exit 4)' "exit $LASTEXITCODE" + Invoke-Native { & $Binary status --port ($port + 1) --config NUL } | Out-Null + Check ($LASTEXITCODE -eq 3) 'status with nothing listening exits 3' "exit $LASTEXITCODE" + + & taskkill.exe /PID $player.Id | Out-Null + Check ($player.WaitForExit(10000)) 'taskkill (no /F) stops it' + Check ((Read-Log $log) -match 'Shutting down') 'and it shut down cleanly' (Read-Log $log) + + # -z: the parent returns, the pidfile names a live copy, and a second -z is refused. + $zlog = Join-Path $Work 'daemon.log' + $pidfile = Join-Path $Work 'daemon.pid' + $zport = 39313 + & $Binary -z -P $pidfile -f $zlog --no-control --port $zport --state-dir "$Work\zstate" @Common + Check ($LASTEXITCODE -eq 0) '-z returns 0 once detached' + Check (Wait-For { (Read-Log $zlog) -match "listening on port $zport" }) 'the detached copy starts' (Read-Log $zlog) + $daemonPid = [int]((Get-Content -LiteralPath $pidfile -ErrorAction SilentlyContinue) | Select-Object -First 1) + $daemon = Get-Process -Id $daemonPid -ErrorAction SilentlyContinue + Check ([bool]$daemon) '-P names a live process' "pidfile holds '$daemonPid'" + + $second = @(Invoke-Native { & $Binary -z -P $pidfile --no-control --port ($zport + 1) --state-dir "$Work\zstate2" @Common }) + Check ($LASTEXITCODE -ne 0 -and ($second -join "`n") -match 'already running') ` + 'a second -z on the same pidfile is refused' ($second -join ' / ') + + if ($daemon) { + & taskkill.exe /PID $daemonPid | Out-Null + Check ($daemon.WaitForExit(10000)) 'taskkill stops the detached copy' + Check (-not (Test-Path -LiteralPath $pidfile)) 'and its pidfile is removed' + } +} finally { + Get-Process -Name sendspin-cli -ErrorAction SilentlyContinue | + Where-Object { $_.Path -eq $Binary } | Stop-Process -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($failures -gt 0) { + Write-Host "$failures check(s) failed" + exit 1 +} +Write-Host 'Every smoke check passed' From 58a92cffe8237711048efe101a8c52f01f93cd5a Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 15:39:33 -0500 Subject: [PATCH 12/15] Add a guided Windows installer scripts/get_started_windows.ps1 mirrors get_started_linux.sh for one user, with no administrator rights: it verifies the release zip against SHA256SUMS, installs it under LocalAppData\Programs, seeds the config from the example, adds the install folder to the user PATH, and registers a scheduled task that runs the player with -z at logon. The task also triggers every minute. While the player runs, that attempt finds the pidfile held and exits before opening the log; once the player has stopped, the next attempt starts it. A crash comes back within a minute, and taskkill /PID stops the detached player cleanly. Task Scheduler's own restart-on-failure does not cover a process that exits after starting, and Stop-ScheduledTask would not reach the detached player, so neither is used. The firewall rule a dialling server needs is offered separately, behind a UAC prompt, and covers private networks only. --- scripts/get_started_windows.ps1 | 405 ++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 scripts/get_started_windows.ps1 diff --git a/scripts/get_started_windows.ps1 b/scripts/get_started_windows.ps1 new file mode 100644 index 0000000..b40ed24 --- /dev/null +++ b/scripts/get_started_windows.ps1 @@ -0,0 +1,405 @@ +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Installs a released sendspin-cli for this Windows user and starts it at every logon. +# Verifies SHA256SUMS, installs under LocalAppData, writes a config and registers a scheduled +# task. Nothing needs administrator rights except the optional firewall rule, asked for apart. +# +# Usage: get_started_windows.ps1 [-Version ] [-Yes] [-Firewall] +# +# -Version install this release instead of the latest, e.g. -Version v0.5.0 +# -Yes do not ask before installing. Required when there is no console to ask on +# -Firewall also add the firewall rule without asking (still needs a UAC prompt) +# +# Environment: +# +# SENDSPIN_CLI_ZIP install this zip instead of downloading a release. The escape hatch for a +# locally built archive, and the only way to exercise this script before a +# release exists. It is NOT checksummed, and the script says so loudly. + +[CmdletBinding()] +param( + [string]$Version = '', + [switch]$Yes, + [switch]$Firewall, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' +# Windows PowerShell 5.1's download progress bar slows Invoke-WebRequest to a crawl. +$ProgressPreference = 'SilentlyContinue' + +# Fixed so this script can never install someone else's binary. +$Repo = 'Sendspin/sendspin-cpp-cli' +$Leg = 'windows-x86_64' +$TaskName = 'sendspin-cli' +$FirewallRule = 'sendspin-cli' +# DNS-SD and AF_UNIX both arrived by Windows 10 1809. +$MinimumBuild = 17763 + +function Fail([string]$Message) { + Write-Host "get_started_windows: FAIL: $Message" -ForegroundColor Red + exit 1 +} + +function Say([string]$Message = '') { + Write-Host $Message +} + +function Step([string]$Message) { + Write-Host '' + Write-Host "==> $Message" +} + +# Runs a native command, discarding its stderr. Windows PowerShell turns stderr into error +# records, which the script's 'Stop' preference would throw on. +function Invoke-Native([scriptblock]$Command) { + $ErrorActionPreference = 'Continue' + & $Command 2>$null +} + +function Test-CanAsk { + return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected +} + +function Confirm-Action([string]$Question) { + $answer = Read-Host "$Question [y/N]" + return $answer -match '^(y|yes)$' +} + +if ($Help) { + # The header's usage block, from '# Usage:' to the end of the comment. + $inUsage = $false + foreach ($line in Get-Content -LiteralPath $PSCommandPath) { + if ($line -match '^# Usage:') { + $inUsage = $true + } + if ($inUsage) { + if ($line -notmatch '^#') { + break + } + Write-Host ($line -replace '^# ?', '') + } + } + exit 0 +} + +# Is this a host this can work on at all + +if ([Environment]::OSVersion.Platform -ne 'Win32NT') { + Fail "Windows only. On Linux use scripts/get_started_linux.sh; on macOS take the .pkg from https://github.com/$Repo/releases" +} +$Build = [Environment]::OSVersion.Version.Build +if ($Build -lt $MinimumBuild) { + Fail "this is Windows build $Build, and sendspin-cli needs $MinimumBuild (Windows 10 1809) or later for its control socket and mDNS" +} +# x64 builds also run on Windows on ARM, through its x64 emulation. +$Arch = $env:PROCESSOR_ARCHITECTURE +if ($Arch -ne 'AMD64' -and $Arch -ne 'ARM64') { + Fail "no release is built for a '$Arch' Windows -- the archive is $Leg. Build from source instead: https://github.com/$Repo#build" +} + +$LocalAppData = [Environment]::GetFolderPath('LocalApplicationData') +$RoamingAppData = [Environment]::GetFolderPath('ApplicationData') +$InstallDir = Join-Path $LocalAppData 'Programs\sendspin-cli' +$Binary = Join-Path $InstallDir 'sendspin-cli.exe' +$ConfigDir = Join-Path $RoamingAppData 'sendspin-cli' +$Config = Join-Path $ConfigDir 'config' +$LogDir = Join-Path $LocalAppData 'sendspin-cli' +$LogFile = Join-Path $LogDir 'sendspin-cli.log' +$PidFile = Join-Path $LogDir 'sendspin-cli.pid' +$User = [Security.Principal.WindowsIdentity]::GetCurrent().Name + +$WorkDir = Join-Path ([IO.Path]::GetTempPath()) ("sendspin-cli-setup-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $WorkDir | Out-Null + +try { + Say 'sendspin-cli getting started' + Say " host: Windows build $Build, $Arch$(if ($Arch -eq 'ARM64') { ' (running the x64 build under emulation)' })" + Say " release leg: $Leg" + Say " user: $User" + + # The payload + + if ($env:SENDSPIN_CLI_ZIP) { + Step 'Using the payload you supplied' + if (-not (Test-Path -LiteralPath $env:SENDSPIN_CLI_ZIP -PathType Leaf)) { + Fail "SENDSPIN_CLI_ZIP names '$env:SENDSPIN_CLI_ZIP', which is not a file" + } + $Zip = (Resolve-Path -LiteralPath $env:SENDSPIN_CLI_ZIP).Path + Say " $Zip" + Say '' + Say ' !! NOT VERIFIED. This is your own archive, so there is no published SHA256SUMS to' + Say ' !! check it against, and nothing here has established that it is what you think.' + Say ' !! Take a release instead of setting SENDSPIN_CLI_ZIP to get that check.' + } else { + Step 'Finding the release to install' + # Windows PowerShell 5.1 may not offer TLS 1.2 by default. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor + [Net.SecurityProtocolType]::Tls12 + + if ($Version) { + $Tag = $Version + } else { + try { + $latest = Invoke-RestMethod -UseBasicParsing "https://api.github.com/repos/$Repo/releases/latest" + } catch { + $status = $_.Exception.Response.StatusCode.value__ + if ($status -eq 404) { + Fail "$Repo has published no releases yet, so there is nothing to download. Build from source -- https://github.com/$Repo#build -- or set SENDSPIN_CLI_ZIP to a zip you built" + } + Fail "could not ask github.com for the latest release of $Repo -- check this machine's network and try again ($($_.Exception.Message))" + } + $Tag = $latest.tag_name + } + # The archives are named for the version, which is the tag without its 'v'. + $Archive = "sendspin-cli-$($Tag.TrimStart('v'))-$Leg.zip" + Say " $Tag -> $Archive" + + Step 'Downloading and verifying' + $Base = "https://github.com/$Repo/releases/download/$Tag" + $Zip = Join-Path $WorkDir $Archive + try { + Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Archive" -OutFile $Zip + } catch { + Fail "no $Archive in release $Tag of $Repo. Check the tag exists and carries a Windows build: https://github.com/$Repo/releases" + } + $SumsFile = Join-Path $WorkDir 'SHA256SUMS' + try { + Invoke-WebRequest -UseBasicParsing -Uri "$Base/SHA256SUMS" -OutFile $SumsFile + } catch { + Fail "$Tag carries $Archive but no SHA256SUMS, so there is nothing to verify it against. Refusing to install an unverified binary" + } + + # A SHA256SUMS that omits this archive verifies nothing, so check it is listed. + $listed = Get-Content $SumsFile | Where-Object { ($_ -split '\s+', 2)[1] -eq $Archive } | + Select-Object -First 1 + if (-not $listed) { + Fail "the SHA256SUMS published with $Tag does not list $Archive, so there is no checksum to verify it against. Nothing has been installed" + } + $expected = ($listed -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Zip).Hash.ToLowerInvariant() + if ($expected -ne $actual) { + Fail "$Archive does not match the checksum $Tag publishes for it. Nothing has been installed. Download it again; if it fails a second time, say so on the issue tracker rather than installing it anyway" + } + Say " ${Archive}: SHA256 matches SHA256SUMS" + } + + # What is in it + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $entries = [IO.Compression.ZipFile]::OpenRead($Zip) + try { + $names = @($entries.Entries | ForEach-Object { $_.FullName }) + } finally { + $entries.Dispose() + } + $roots = @($names | ForEach-Object { ($_ -split '/')[0] } | Sort-Object -Unique) + if ($roots.Count -ne 1) { + Fail "'$Zip' has $($roots.Count) top-level entries, and a payload has one folder holding sendspin-cli.exe" + } + $PayloadRoot = $roots[0] + if ($names -notcontains "$PayloadRoot/sendspin-cli.exe") { + Fail "'$Zip' holds no $PayloadRoot/sendspin-cli.exe -- a Linux or macOS archive would look exactly like this. Take the $Leg one" + } + + # The plan + + $SeedConfig = -not (Test-Path -LiteralPath $Config) + $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $AddToPath = -not (($UserPath -split ';') -contains $InstallDir) + $HaveRule = $false + try { + $HaveRule = [bool](Get-NetFirewallRule -DisplayName $FirewallRule -ErrorAction Stop) + } catch { + $HaveRule = $false + } + + Step 'This is what it will do, as you, with no administrator rights' + Say '' + Say " install $InstallDir\ (sendspin-cli.exe and its README, LICENSE and config example)" + if ($SeedConfig) { + Say " config $Config (from the example; every line in it is commented out)" + } else { + Say " config $Config (exists; left as it is)" + } + if ($AddToPath) { + Say " PATH add $InstallDir to your user PATH, so 'sendspin-cli status' works anywhere" + } + Say " task '$TaskName' in Task Scheduler: start the player, without a window, when you" + Say " log on, logging to $LogFile; it checks every minute" + Say ' and starts the player again if it has stopped' + Say ' start the task, now' + Say '' + Say 'A running player is stopped first, so re-running this upgrades it.' + Say 'With nothing in the config, it plays through your default output device, follows it when' + Say 'you change it, and advertises itself so a Sendspin server on this network can find it.' + + if (-not $Yes) { + if (-not (Test-CanAsk)) { + Fail 'there is no console to confirm that with. Re-run with -Yes if you have read it and want it done' + } + if (-not (Confirm-Action "`nGo ahead?")) { + Fail 'nothing was installed' + } + } + + # Install + + Step 'Installing' + # Disabled first, so its once-a-minute check cannot start the old binary mid-copy. + if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Disable-ScheduledTask -TaskName $TaskName | Out-Null + } + # Any player run from the install folder holds the binary open. + Get-CimInstance Win32_Process -Filter "Name = 'sendspin-cli.exe'" | + Where-Object { $_.ExecutablePath -eq $Binary } | + ForEach-Object { + Say " stopping the running player (pid $($_.ProcessId))" + # Without /F: its close handler says goodbye to the server first. + $target = $_.ProcessId + Invoke-Native { & taskkill.exe /PID $target } | Out-Null + Wait-Process -Id $_.ProcessId -Timeout 10 -ErrorAction SilentlyContinue + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + + $Unpacked = Join-Path $WorkDir 'unpacked' + [IO.Compression.ZipFile]::ExtractToDirectory($Zip, $Unpacked) + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Copy-Item -Path (Join-Path (Join-Path $Unpacked $PayloadRoot) '*') -Destination $InstallDir -Recurse -Force + # Downloaded files carry the internet zone mark; this one was verified above. + Get-ChildItem -LiteralPath $InstallDir -File | Unblock-File + Say " $Binary" + & $Binary --version | ForEach-Object { Say " $_" } + + if ($SeedConfig) { + New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null + Copy-Item -LiteralPath (Join-Path $InstallDir 'sendspin-cli.conf.example') -Destination $Config + Say " $Config (from the example; everything in it is commented out)" + } + + if ($AddToPath) { + $newPath = if ($UserPath) { "$UserPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Say " PATH: added $InstallDir (new terminals pick it up)" + } + + # The task + + Step 'Starting it at logon' + New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + # -z detaches into a hidden console of its own, which taskkill /PID stops cleanly; the + # headless conhost spares the short-lived parent a window. While a player holds the + # pidfile, the minute's attempt refuses before opening the log and exits. + $action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" ` + -Argument "--headless `"$Binary`" -z -P `"$PidFile`" -f `"$LogFile`"" -WorkingDirectory $InstallDir + $atLogon = New-ScheduledTaskTrigger -AtLogOn -User $User + # A logon trigger's own repetition only starts at the next logon, so a second trigger checks. + $everyMinute = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(10) ` + -RepetitionInterval (New-TimeSpan -Minutes 1) + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew + $principal = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $atLogon, $everyMinute ` + -Settings $settings -Principal $principal -Description 'Sendspin audio player (sendspin-cli)' ` + -Force | Out-Null + Say " task: '$TaskName' starts the player when $User logs on, and again within a minute if it stops" + + Start-ScheduledTask -TaskName $TaskName + Start-Sleep -Seconds 4 + $running = Get-CimInstance Win32_Process -Filter "Name = 'sendspin-cli.exe'" | + Where-Object { $_.ExecutablePath -eq $Binary } + if ($running) { + Step 'sendspin-cli is running' + if (Test-Path -LiteralPath $LogFile) { + Get-Content -LiteralPath $LogFile -Tail 4 | ForEach-Object { Say " $_" } + } + } else { + Step 'sendspin-cli was started and is NOT running' + Say '' + Say ' What it said:' + if (Test-Path -LiteralPath $LogFile) { + Get-Content -LiteralPath $LogFile -Tail 15 | ForEach-Object { Say " $_" } + } else { + Say " (no log at $LogFile)" + } + Say '' + Say " '$Binary -l' lists the devices this machine has." + } + + # The firewall + + Step 'Letting a server reach it' + if ($HaveRule) { + Say " The '$FirewallRule' firewall rule is already there." + } else { + Say ' A server finds the player over mDNS and then connects to it on port 8928. Windows' + Say ' Firewall blocks that connection until a rule allows it, and adding one needs' + Say ' administrator rights. The rule this adds lets sendspin-cli.exe accept connections' + Say ' on private networks only:' + Say '' + Say " New-NetFirewallRule -DisplayName $FirewallRule -Direction Inbound -Action Allow -Program '$Binary' -Protocol TCP -Profile Private" + Say '' + $addRule = $Firewall + if (-not $addRule -and -not $Yes -and (Test-CanAsk)) { + $addRule = Confirm-Action 'Add it now? Windows asks for administrator approval' + } + if ($addRule) { + $command = "New-NetFirewallRule -DisplayName '$FirewallRule' -Direction Inbound -Action Allow " + + "-Program '$Binary' -Protocol TCP -Profile Private | Out-Null" + try { + $elevated = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru ` + -ArgumentList '-NoProfile', '-NonInteractive', '-Command', $command + if ($elevated.ExitCode -eq 0) { + Say " Added the '$FirewallRule' rule." + } else { + Say " The rule was not added (exit $($elevated.ExitCode)); run the command above as administrator." + } + } catch { + Say ' Administrator approval was declined, so the rule was not added. Run the command above' + Say ' from an administrator PowerShell when you want it; until then, dial a server' + Say " yourself with 'server = ' in the config." + } + } else { + Say ' Not added. Until it is, a server cannot dial in: dial out instead with' + Say " 'server = ' in the config, or run the command above as administrator." + } + } + + # What to do next + + Step 'Next' + Say '' + Say ' Ask it what it is doing (in a new terminal, so PATH is up to date):' + Say '' + Say ' sendspin-cli status' + Say '' + Say ' Pair it from your Sendspin server: when the server asks for a code, it is in the log.' + Say '' + Say " Get-Content '$LogFile' -Wait -Tail 20" + Say '' + Say " Change what it plays through, or anything else, in $Config --" + Say " 'sendspin-cli -l' lists the devices -- then stop it, and the task starts it again" + Say ' within a minute with the new settings:' + Say '' + Say " taskkill /PID (Get-Content '$PidFile')" + Say '' + Say ' To keep it stopped, disable the task first:' + Say '' + Say " Disable-ScheduledTask $TaskName; taskkill /PID (Get-Content '$PidFile')" + Say '' +} finally { + Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue +} From 5fc777f0965f9279e5d4b096822d96550ef8e081 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 15:39:33 -0500 Subject: [PATCH 13/15] Document Windows A Getting Started on Windows page (the installer, pairing, stopping and starting, the firewall rule, SmartScreen, where things are, uninstalling, and running from the zip by hand), Windows in the README and Installation, the Windows config and state paths, a Windows section in Troubleshooting, and roadmap item 16 for what is still owed: a service, a system-wide config, signing and log rotation. --- README.md | 17 ++- docs/ROADMAP.md | 19 +++ docs/wiki/Configuration.md | 8 +- docs/wiki/Getting-Started-on-Windows.md | 183 ++++++++++++++++++++++++ docs/wiki/Installation.md | 42 +++++- docs/wiki/Running-as-a-Service.md | 4 +- docs/wiki/Troubleshooting.md | 23 +++ docs/wiki/_Sidebar.md | 1 + 8 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 docs/wiki/Getting-Started-on-Windows.md diff --git a/README.md b/README.md index 207be7b..f13049d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ [Sendspin](https://github.com/Sendspin/spec) protocol. It appears on your network, a Sendspin server finds it, and it plays synchronized audio with the rest of your group. -It runs on Linux and Apple-silicon macOS. On Linux, it can also run as a system service. +It runs on Linux, Apple-silicon macOS and Windows 10 1809 or later. On Linux, it can also run as a +system service; on Windows, it can start whenever you log on. ## How can I run it? @@ -33,6 +34,20 @@ Download the Apple-silicon `.pkg` installer or tarball from [Releases](https://github.com/Sendspin/sendspin-cpp-cli/releases), then follow the [installation guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Installation#macos). +### Windows + +The guided Windows installer needs no administrator rights: + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_windows.ps1 -OutFile get_started_windows.ps1 +notepad get_started_windows.ps1 +powershell -ExecutionPolicy Bypass -File .\get_started_windows.ps1 +``` + +It verifies the release, installs it for your user and starts it whenever you log on. See +[Getting Started on Windows](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-Windows) +for the walkthrough, including the firewall rule a server needs to reach it. + ### Start playing Once installed and configured, start a player from a terminal: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 74d4bc5..f941316 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -212,3 +212,22 @@ mDNS half lands under its own name. The Python CLI's `--interface` takes an IP address only, and `aiosendspin` binds to it without scoping its advertisement, so this flag is a superset of it. + +### 16. Windows + +The player runs on Windows 10 1809+ at parity with Linux and macOS, from a per-user install +that starts at logon. Still owed: + +- **A Windows service**, so a player runs before anyone logs on. It needs Service Control + Manager integration, a service account, a state directory under that account's profile, + and a deliberate ACL on the control socket so an administrator can drive it — the + counterpart of the `sudo` the Linux unit needs. +- **A system-wide config.** ProgramData lets any user create files, so a file there needs an + ACL check (owned by, and writable only by, SYSTEM or Administrators) before a service could + trust it. +- **Signing.** The exe is unsigned, so SmartScreen warns on first run. +- **Log rotation.** There is no SIGHUP to reopen `-f` on, so the logfile only grows. +- **Logoff and shutdown** are handled through the console control events and are untested. +- **PortAudio picks MME's default device**, with around 180 ms of device latency; WASAPI + would cut that. `-l` lists the WASAPI devices, and `-o portaudio:` picks one. +- **A no-mDNS build's `-s mdns:` refusal** names Linux packages to install, even on Windows. diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index f76cae2..4c2bf20 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -40,6 +40,11 @@ top: 3. `$HOME/.config/sendspin-cli/config` 4. `/etc/sendspin-cli.conf` +On Windows, the search is `$XDG_CONFIG_HOME/sendspin-cli/config`, then +`%APPDATA%\sendspin-cli\config`, and there is **no system-wide file**: unlike `/etc`, any user +can create files under ProgramData, so a config there could not be trusted by a player running +as someone else. `--config NUL` is Windows' `--config /dev/null`. + Finding none is silent and normal. There is no `--no-config` flag — `--config /dev/null` already does that. @@ -171,7 +176,8 @@ A state file written by 0.3.0 or earlier is upgraded in place: its `static-delay `blob-output_delay` once, and `last-server-hash` is dropped. It lives at `$XDG_STATE_HOME/sendspin-cli/state`, then -`$HOME/.local/state/sendspin-cli/state`, and **`--state-dir ` overrides both** — a +`$HOME/.local/state/sendspin-cli/state` (on Windows, `%LOCALAPPDATA%\sendspin-cli\state`, +readable by you alone), and **`--state-dir ` overrides both** — a systemd *system* unit has neither variable and is handed `/var/lib/sendspin-cli` by `StateDirectory=`. With none of the three the player still runs and simply remembers nothing — which now includes its identity, so every run is a new, unpaired player to a diff --git a/docs/wiki/Getting-Started-on-Windows.md b/docs/wiki/Getting-Started-on-Windows.md new file mode 100644 index 0000000..25bccf6 --- /dev/null +++ b/docs/wiki/Getting-Started-on-Windows.md @@ -0,0 +1,183 @@ +# Getting Started on Windows + +From nothing to a player your Sendspin server can find, running whenever you are logged on. + +**You need:** Windows 10 1809 or later, or Windows 11, on x64 (Windows on ARM runs the x64 +build through its emulation), and something to play through. You do **not** need +administrator rights, except for the optional firewall rule. + +## The short way + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_windows.ps1 -OutFile get_started_windows.ps1 +notepad get_started_windows.ps1 # it is about to install things; read it +powershell -ExecutionPolicy Bypass -File .\get_started_windows.ps1 +``` + +It prints what it is about to do, as you, and waits for you to say yes: + +``` +==> This is what it will do, as you, with no administrator rights + + install C:\Users\you\AppData\Local\Programs\sendspin-cli\ (sendspin-cli.exe and its README, LICENSE and config example) + config C:\Users\you\AppData\Roaming\sendspin-cli\config (from the example; every line in it is commented out) + PATH add C:\Users\you\AppData\Local\Programs\sendspin-cli to your user PATH, so 'sendspin-cli status' works anywhere + task 'sendspin-cli' in Task Scheduler: start the player, without a window, when you + log on, logging to C:\Users\you\AppData\Local\sendspin-cli\sendspin-cli.log; it checks every minute + and starts the player again if it has stopped + start the task, now + +Go ahead? [y/N] +``` + +`-Yes` skips the prompt, and is required when there is no console to ask on. `-Version v0.5.0` +installs a specific release instead of the newest. `-Firewall` adds the firewall rule without +asking, though Windows still asks for administrator approval. + +### What it actually does + +1. **Checks this is Windows 10 1809 or later**, where the player's control socket and mDNS + both work, on x64 or ARM64. +2. **Finds the newest release** and downloads its `windows-x86_64` zip plus `SHA256SUMS`. +3. **Verifies the checksum**, and stops without installing anything if it does not match. +4. **Installs into your own LocalAppData**, `AppData\Local\Programs\sendspin-cli`, and clears + the mark Windows puts on downloaded files, since it has just verified them. A player + already running from there is stopped cleanly first, so running the script again upgrades + it. +5. **Copies the annotated example config** to `AppData\Roaming\sendspin-cli\config` if you + have none. Every line in it is commented out, so it chooses nothing: the player plays + through your **default output device**, and follows it when you change it. +6. **Adds the install folder to your user PATH**, so `sendspin-cli status` works in any new + terminal. +7. **Registers a scheduled task** that runs `sendspin-cli -z` when you log on, with no window. + The task also checks every minute: while the player runs, that check finds its pidfile + held and does nothing, and if the player has stopped, it starts it again. +8. **Starts it now**, and shows the last lines of its log. +9. **Offers the firewall rule** — see [Letting a server reach it](#letting-a-server-reach-it). + +## Pair it + +Your Sendspin server finds the player over mDNS. When you pair it from the server, the +player prints a six-digit code in its log; type it into the server: + +```powershell +Get-Content $env:LOCALAPPDATA\sendspin-cli\sendspin-cli.log -Wait -Tail 20 +``` + +``` +I cli: *** Pairing code: 110948 *** -- type it into the server to pair +``` + +Each pairing attempt has its own code, so keep the server's dialog open while you read it. +Closing the dialog cancels the attempt and withdraws the code. [Pairing a Player](Pairing-a-Player) +has the rest. + +## Drive it + +```powershell +sendspin-cli status # what it is doing +sendspin-cli vol 30 # and the other subcommands +``` + +These talk to the player over its control socket, in your LocalAppData, which only you can +reach. See [Controlling the Player](Controlling-the-Player). + +**To restart it with new settings**, stop it; the task starts it again within a minute: + +```powershell +taskkill /PID (Get-Content $env:LOCALAPPDATA\sendspin-cli\sendspin-cli.pid) +``` + +`taskkill` without `/F` is a clean stop: the player says goodbye to the server first. **To keep +it stopped**, disable the task before stopping it, and enable it again to bring it back: + +```powershell +Disable-ScheduledTask sendspin-cli +taskkill /PID (Get-Content $env:LOCALAPPDATA\sendspin-cli\sendspin-cli.pid) + +Enable-ScheduledTask sendspin-cli +``` + +`Stop-ScheduledTask` is **not** the way to stop it: the task has already handed over to the +detached player, so there is nothing for it to end. + +## Letting a server reach it + +A server finds the player over mDNS and then **connects to it** on port 8928. Windows +Firewall blocks that connection until a rule allows it, and adding a rule needs +administrator rights, so the script offers it apart from everything else: + +```powershell +New-NetFirewallRule -DisplayName sendspin-cli -Direction Inbound -Action Allow -Program "$env:LOCALAPPDATA\Programs\sendspin-cli\sendspin-cli.exe" -Protocol TCP -Profile Private +``` + +The rule covers **private networks only**. If Windows has your home network marked Public, +change that in Settings → Network & internet, or the rule does not apply. + +Without the rule, the player can still **dial a server itself**: put `server = ` in the +config, or `server = mdns:` to find one over mDNS. See +[Connection modes](Advanced-Usage#connection-modes). + +## SmartScreen + +The release is **unsigned**, so the first time it runs, Windows SmartScreen may say it +protected your PC. The script checks the zip against the `SHA256SUMS` the release publishes +before installing it; if you got the binary some other way, check it the same way. A signed +build is tracked in the [roadmap](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md). + +## Where things are + +| What | Where | +|---|---| +| The player | `%LOCALAPPDATA%\Programs\sendspin-cli\sendspin-cli.exe` | +| Your config | `%APPDATA%\sendspin-cli\config` | +| What it remembers: its identity and pairings, volume, last server | `%LOCALAPPDATA%\sendspin-cli\state` | +| Its log | `%LOCALAPPDATA%\sendspin-cli\sendspin-cli.log` | +| Its pidfile | `%LOCALAPPDATA%\sendspin-cli\sendspin-cli.pid` | +| Its control socket | `%LOCALAPPDATA%\sendspin-cli-8928.sock` | + +The state file and the control socket's lock are readable by you alone. The log is never +rotated, so it only grows; it is safe to delete while the player is stopped. + +## Uninstalling + +```powershell +Disable-ScheduledTask sendspin-cli +taskkill /PID (Get-Content $env:LOCALAPPDATA\sendspin-cli\sendspin-cli.pid) +Unregister-ScheduledTask sendspin-cli -Confirm:$false +$dir = "$env:LOCALAPPDATA\Programs\sendspin-cli" +[Environment]::SetEnvironmentVariable('Path', (([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $dir }) -join ';'), 'User') +Remove-Item $dir -Recurse +``` + +Your config, state and log are left where they are, so reinstalling keeps the player's +identity and pairings. Remove `%APPDATA%\sendspin-cli` and `%LOCALAPPDATA%\sendspin-cli` too +to forget it entirely; a server then sees a new, unpaired player. The firewall rule, if you +added it, goes from an administrator PowerShell: + +```powershell +Remove-NetFirewallRule -DisplayName sendspin-cli +``` + +## Without the script + +Take `sendspin-cli--windows-x86_64.zip` and `SHA256SUMS` from +[Releases](https://github.com/Sendspin/sendspin-cpp-cli/releases), check one against the +other, and run it from wherever you unpacked it: + +```powershell +Get-FileHash .\sendspin-cli-0.5.0-windows-x86_64.zip -Algorithm SHA256 # compare with SHA256SUMS +Expand-Archive .\sendspin-cli-0.5.0-windows-x86_64.zip . +Get-ChildItem .\sendspin-cli-0.5.0-windows-x86_64 | Unblock-File +.\sendspin-cli-0.5.0-windows-x86_64\sendspin-cli.exe -l +.\sendspin-cli-0.5.0-windows-x86_64\sendspin-cli.exe +``` + +`-l` lists what it can play through; `-o portaudio:` picks one. Ctrl+C and closing +the window stop it cleanly, and it handles logging off and shutting down the same way. + +## Next + +- [Configuration](Configuration) — every setting, and how the config file is found +- [Controlling the Player](Controlling-the-Player) — the subcommands +- [Troubleshooting](Troubleshooting#windows) — when nothing finds it diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index d5e4b55..f5e7a38 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -1,25 +1,27 @@ # Installation -Four ways in, depending on what you have. If you are on Linux and want the short version, -[Getting Started on Linux](Getting-Started-on-Linux) does all of this in one command. +Five ways in, depending on what you have. If you are on Linux and want the short version, +[Getting Started on Linux](Getting-Started-on-Linux) does all of this in one command; +[Getting Started on Windows](Getting-Started-on-Windows) is the same for Windows. | You have | Take | |---|---| | A Linux box or a Raspberry Pi | The `linux-x86_64`, `linux-arm64`, `linux-armv7` or `linux-armv6` tarball | | An Apple-silicon Mac | The `macos-arm64` installer `.pkg`, or the tarball | +| A Windows 10 1809+ or Windows 11 PC | The `windows-x86_64` zip | | Anything else | [Build from source](#build-from-source) | Everything published is on the [Releases page](https://github.com/Sendspin/sendspin-cpp-cli/releases), and every release -carries all six archives. Per-commit builds of unreleased work are under the repository's -Actions tab and expire after 14 days — five archives per commit there, `linux-armv6` being +carries all seven archives. Per-commit builds of unreleased work are under the repository's +Actions tab and expire after 14 days — six archives per commit there, `linux-armv6` being built by a workflow of its own rather than on every push. See [the Actions tab](https://github.com/Sendspin/sendspin-cpp-cli/actions). ## What is in the archive Every tarball is a staged `cmake --install` payload, which means **every path under `usr/` -is the path the file installs to**: +is the path the file installs to** (the Windows zip is flat instead; see [Windows](#windows)): ``` sendspin-cli-0.1.0-linux-arm64/ @@ -140,6 +142,26 @@ A Developer ID signature and notarization are owed and tracked as roadmap item 7 There is no launchd job in the payload. On macOS the player runs from a shell or under a launch agent you write. +## Windows + +The zip holds one folder: `sendspin-cli.exe`, `BUILD-INFO.txt`, `README.md`, +`contributors.md`, `LICENSE` and `sendspin-cli.conf.example`. **It needs nothing installed +alongside it** — PortAudio is linked in, and mDNS is Windows' own. + +[Getting Started on Windows](Getting-Started-on-Windows) installs it for you, verified and +starting at logon. By hand: + +```powershell +Get-FileHash .\sendspin-cli-0.5.0-windows-x86_64.zip -Algorithm SHA256 # compare with SHA256SUMS +Expand-Archive .\sendspin-cli-0.5.0-windows-x86_64.zip . +Get-ChildItem .\sendspin-cli-0.5.0-windows-x86_64 | Unblock-File +.\sendspin-cli-0.5.0-windows-x86_64\sendspin-cli.exe --version +``` + +**The binary is not signed**, so SmartScreen may warn the first time it runs; that is what +checking it against `SHA256SUMS` is for. There is no Windows service in the payload: the +guided installer starts it at logon from a scheduled task instead. + ## Raspberry Pi The Pi takes the `linux-arm64` archive on a 64-bit OS and the `linux-armv7` one on a 32-bit @@ -166,6 +188,16 @@ cmake --build build ./build/sendspin-cli --help ``` +On Windows, build with clang-cl from a Visual Studio developer prompt with the +"C++ Clang tools for Windows" component installed; PortAudio is built from source and mDNS +needs nothing: + +```powershell +cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl +cmake --build build +.\build\sendspin-cli.exe --help +``` + Needs CMake ≥ 3.16, a C++20 compiler, and network access on the first configure — sendspin-cpp is pulled in with `FetchContent` at a pinned tag and fetches its own dependencies in turn. diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index 157f80f..662dc9d 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -1,6 +1,8 @@ # Running as a Service -One systemd unit ships in the Linux payload, with sane defaults and nothing to fill in. +One systemd unit ships in the Linux payload, with sane defaults and nothing to fill in. On +Windows there is no service yet: [Getting Started on Windows](Getting-Started-on-Windows) +sets up a scheduled task that starts the player whenever you log on. ```bash sudo systemd-sysusers diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 7dfe2ad..fe64cbf 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -215,6 +215,29 @@ avahi-browse -rt _sendspin._tcp # Linux dns-sd -B _sendspin._tcp # macOS ``` +## Windows + +**A server cannot find it, or finds it and cannot connect.** The server dials the player on +port 8928, which Windows Firewall blocks until a rule allows it. Add the rule +[Getting Started on Windows](Getting-Started-on-Windows#letting-a-server-reach-it) shows, from +an administrator PowerShell, and check Windows has the network marked Private: the rule +covers private networks only. Or have the player dial out, with `server = `. + +**"this host's own per-user directory cannot hold a control socket"** names your LocalAppData +and someone it lets write there. The player refuses to put its control socket anywhere +another account can write, so it runs without one rather than in a shared folder. Give +`--control-socket ` in a folder only you can write, or remove the extra permission. + +**SmartScreen says it protected your PC.** The build is unsigned. Check the zip against +`SHA256SUMS` before choosing to run it anyway. + +**`taskkill` without `/F` reports SUCCESS, and the player keeps running.** `taskkill` asks a +process to close through its console window, and that player has none — it was started under +`conhost --headless`, say. A player the guided installer started runs with `-z`, which gives +it a hidden console of its own that `taskkill` can close. For any other, `taskkill /F` stops +it at once: the server sees the connection drop instead of a goodbye, and nothing on disk is +left inconsistent. + ## The subcommands cannot find the player ```console diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index e12f033..c34fbc7 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -6,6 +6,7 @@ - [Getting Started on Linux](Getting-Started-on-Linux) - [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) +- [Getting Started on Windows](Getting-Started-on-Windows) - [Installation](Installation) **Use it** From 7b5fcec058fe7ba2d39a4dd087b2a986336b2e88 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 15:41:28 -0500 Subject: [PATCH 14/15] Accept an Administrators-owned private directory on Windows Windows Server, and any elevated administrator, gives what it creates to BUILTIN\Administrators rather than to the user, so the CI runner's LocalAppData failed the control socket's privacy check and the player had no default socket. Administrators are already trusted as writers there and can take ownership of anything, so trusting them as owner adds no exposure. The tests also learn what the runner showed: the built-in Administrator account's SID prints back as the alias LA, so the owner-only ACL check now compares against the same text Windows produces, and the owned-by-someone-else case uses the Windows folder, which TrustedInstaller owns on every machine. --- src/win_security.cpp | 3 ++- src/win_security.h | 4 ++-- tests/control_test.cpp | 10 ++++++++-- tests/file_access.h | 18 ++++++++++++++++-- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/win_security.cpp b/src/win_security.cpp index 4b186c5..07f8536 100644 --- a/src/win_security.cpp +++ b/src/win_security.cpp @@ -165,7 +165,8 @@ bool is_private_directory(const std::string& path, std::string& reason) { reason = "cannot tell which user this is: " + last_error_text(); return false; } - if (EqualSid(owner, user.data()) == 0) { + // Administrators own what an elevated administrator creates, and could take ownership anyway. + if (EqualSid(owner, user.data()) == 0 && !is_well_known(owner, WinBuiltinAdministratorsSid)) { reason = path + " is owned by " + account_name(owner) + ", not by this user"; return false; } diff --git a/src/win_security.h b/src/win_security.h index 07a5e2e..9d35b7b 100644 --- a/src/win_security.h +++ b/src/win_security.h @@ -26,8 +26,8 @@ std::string local_app_data_dir(); /// This user's roaming AppData folder, the counterpart of ~/.config; as local_app_data_dir(). std::string roaming_app_data_dir(); -/// True if `path` is a directory owned by this user that no one but SYSTEM and Administrators -/// can also write to, or create files in that others could write. +/// True if `path` is a directory owned by this user (or Administrators) that no one but SYSTEM +/// and Administrators can also write to, or create files in that others could write. bool is_private_directory(const std::string& path, std::string& reason); /// Opens `path` read-write as a CRT descriptor, creating it if missing, like POSIX open(). diff --git a/tests/control_test.cpp b/tests/control_test.cpp index e3c1842..9271662 100644 --- a/tests/control_test.cpp +++ b/tests/control_test.cpp @@ -1256,14 +1256,20 @@ TEST(PrivateRuntimeDir, AMissingPathIsRefused) { } TEST(PrivateRuntimeDir, ADirectoryOwnedBySomeoneElseIsRefused) { -#ifndef _WIN32 +#ifdef _WIN32 + // TrustedInstaller owns the Windows folder everywhere; the drive root's owner varies. + const char* root = std::getenv("SystemRoot"); + ASSERT_NE(root, nullptr); + const std::string other = root; +#else // Root owns / and would pass. if (::geteuid() == 0) { GTEST_SKIP() << "running as root, which owns /"; } + const std::string other = "/"; #endif std::string reason; - EXPECT_FALSE(is_private_runtime_dir("/", reason)); + EXPECT_FALSE(is_private_runtime_dir(other, reason)); EXPECT_NE(reason.find("not by this user"), std::string::npos) << reason; } diff --git a/tests/file_access.h b/tests/file_access.h index 9a99d82..f13ad00 100644 --- a/tests/file_access.h +++ b/tests/file_access.h @@ -103,8 +103,22 @@ inline bool owner_only(const std::string& path, std::string& detail) { LocalFree(text); } LocalFree(descriptor); - // Protected, and one allow ACE for this user. - return detail == "D:P(A;;FA;;;" + current_user_sid() + ")"; + + // Protected, and one allow ACE for this user, spelled the way Windows prints it back: a + // well-known account comes back as its alias (LA for the built-in Administrator). + std::string expected = "D:P(A;;FA;;;" + current_user_sid() + ")"; + PSECURITY_DESCRIPTOR built = nullptr; + if (ConvertStringSecurityDescriptorToSecurityDescriptorA(expected.c_str(), SDDL_REVISION_1, + &built, nullptr) != 0) { + char* canonical = nullptr; + if (ConvertSecurityDescriptorToStringSecurityDescriptorA( + built, SDDL_REVISION_1, DACL_SECURITY_INFORMATION, &canonical, nullptr) != 0) { + expected = canonical; + LocalFree(canonical); + } + LocalFree(built); + } + return detail == expected; } #endif From 9048e8dd36ddc1654cfffe9fd5ff2fe2c507dfd1 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Mon, 26 Oct 2026 23:33:04 -0500 Subject: [PATCH 15/15] Pin sendspin-cpp to #213 for Winsock init --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 595606a..86b5ae4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,7 +31,7 @@ endif() # sendspin-cpp -set(SENDSPIN_GIT_TAG "763be9cc536d5a333ffb2fede48c79978575c077" CACHE STRING +set(SENDSPIN_GIT_TAG "d961f5fb4d2c95e10162f5939d086860e5aeb501" CACHE STRING "sendspin-cpp git tag or commit to build against") # player, metadata, controller (group transport verbs) and source; no FORCE, so -D overrides still win.