diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fd91b7..f135355 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 @@ -106,6 +107,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: true + avahi: false + systemd: false + steps: # Actions pinned to commits; the trailing comment is the version. - name: Check out @@ -124,6 +142,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 +282,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 @@ -331,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 @@ -413,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 }} @@ -519,7 +577,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: | @@ -873,8 +931,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 }} @@ -883,6 +950,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/CMakeLists.txt b/CMakeLists.txt index d95a2ac..86b5ae4 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 "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. @@ -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) @@ -186,14 +204,19 @@ 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) set(SENDSPIN_CLI_MDNS_DESCRIPTION "none") if(SENDSPIN_CLI_WITH_MDNS) - if(APPLE) + if(WIN32) + # 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) set(SENDSPIN_CLI_MDNS_DESCRIPTION "dns_sd (Bonjour, built in)") @@ -214,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. @@ -238,7 +261,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,7 +282,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 @@ -270,8 +297,23 @@ add_library(sendspin-cli-core STATIC src/supported_formats.cpp ) +# 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_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_posix.cpp) +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. @@ -319,7 +361,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() @@ -433,7 +475,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/README.md b/README.md index a7aed3c..b98945e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [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. +It runs on Linux, Apple-silicon macOS and Windows 10 1809 or later. ## Quick start @@ -39,6 +39,20 @@ Download the Apple-silicon `.pkg` installer or tarball from [installation guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Installation#macos) and start a player from a terminal with `sendspin-cli -n living-room`. +### 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. + ### Pair it with your server The player advertises itself on the local network and a Sendspin server finds it. @@ -49,7 +63,7 @@ Print the token to paste into the server: # Linux service, as installed above sudo sendspin-cli pair-token --control-socket /run/sendspin-cli/control.sock -# --user-service, macOS, or a player started from your own shell +# --user-service, macOS, Windows, or a player started from your own shell sendspin-cli pair-token ``` 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 9743c6d..a00255b 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. @@ -172,7 +177,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 0a2afa0..fc9544c 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/ @@ -146,6 +148,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 @@ -172,6 +194,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 40ec67f..f30e20d 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -1,5 +1,8 @@ # Running as a Service +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. + Two systemd units ship in the Linux payload: a hardened system unit, which this page is mostly about, and [a user unit](#a-user-unit-instead). The [getting-started script](Getting-Started-on-Linux) sets either one up; by hand, the system 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** 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 +} 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' diff --git a/src/cli.cpp b/src/cli.cpp index ae9ed27..55d236e 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -22,9 +22,17 @@ #include "mdns.h" #include "supported_formats.h" +#ifdef _WIN32 +#include "getopt_win.h" + +#include + +#include +#else #include #include #include +#endif #include #include @@ -509,6 +517,16 @@ 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 + // 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(typed, ignored); + return absolute.empty() ? path : absolute.generic_string(); +#else if (!path.empty() && path.front() == '/') { return path; } @@ -517,6 +535,7 @@ std::string absolute_path(const std::string& path) { return path; } return std::string(cwd) + "/" + path; +#endif } /// The column --help wraps at. @@ -545,7 +564,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 @@ -1016,6 +1036,10 @@ void print_usage(std::FILE* out, const char* prog) { 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"); +#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"); std::fprintf(out, " another instance already holds it. A file left by a\n"); @@ -1028,8 +1052,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"); @@ -1100,10 +1128,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"); @@ -1116,12 +1145,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"); @@ -1130,7 +1165,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"); +#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"); @@ -1291,11 +1330,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/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/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..68c2556 --- /dev/null +++ b/src/daemon_win.cpp @@ -0,0 +1,176 @@ +// 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 a detached copy in place of fork(). + +#include "daemon.h" + +#include "win_security.h" + +#include +#include +#include + +#include +#include +#include + +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 = {}; + 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) { + // 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); + // 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; + } + 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 diff --git a/src/getopt_win.h b/src/getopt_win.h new file mode 100644 index 0000000..767eced --- /dev/null +++ b/src/getopt_win.h @@ -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. + +/// 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; +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] == ':' ? ':' : '?'; +} + +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, + 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) { + return -1; + } + if (!getopt_win::is_option(argv[optind]) && + !getopt_win::permute(argc, argv, optstring, longopts)) { + 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.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 5928547..12ba0c9 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 +/// 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. struct HookContext { std::string server_id; ///< SENDSPIN_SERVER_ID: the connected server's id @@ -34,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: @@ -58,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. @@ -67,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_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 diff --git a/src/log.cpp b/src/log.cpp index c524dc5..17eafe0 100644 --- a/src/log.cpp +++ b/src/log.cpp @@ -15,7 +15,16 @@ #include "log.h" #include +#ifdef _WIN32 +#include +// The CRT's names for these. +#define O_CLOEXEC _O_NOINHERIT +#define STDERR_FILENO 2 +#else #include +// Only Windows translates line endings. +#define O_BINARY 0 +#endif #include #include @@ -69,9 +78,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'; } @@ -106,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/main.cpp b/src/main.cpp index 84231ef..b05b75e 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]); @@ -673,10 +710,15 @@ 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); +#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 = {}; @@ -685,6 +727,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/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 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..07f8536 --- /dev/null +++ b/src/win_security.cpp @@ -0,0 +1,246 @@ +// 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; +} + +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(id, 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; +} + +} // 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) { + 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; + } + // 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; + } + 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 + " is writable by " + account_name(trustee); + 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..9d35b7b --- /dev/null +++ b/src/win_security.h @@ -0,0 +1,42 @@ +// 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(); + +/// 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 (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(). +/// @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 diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp index a265a25..424238a 100644 --- a/tests/cli_test.cpp +++ b/tests/cli_test.cpp @@ -613,8 +613,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) @@ -1202,7 +1202,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 db26d69..ca688a7 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; @@ -441,7 +465,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 @@ -586,7 +614,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; } @@ -627,7 +655,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..9271662 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,27 @@ TEST(PrivateRuntimeDir, AMissingPathIsRefused) { } TEST(PrivateRuntimeDir, ADirectoryOwnedBySomeoneElseIsRefused) { +#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; } 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 +1292,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 +1326,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..f13ad00 --- /dev/null +++ b/tests/file_access.h @@ -0,0 +1,125 @@ +// 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, 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 + +} // 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 757d363..18e6456 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