diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6da6fdc..7bf2bc6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -185,11 +185,15 @@ jobs:
# checkout; windows-msvc-ci.yml A/B-validates this same path on every PR.
- name: Build satellite.exe (MSVC + vcpkg, hardened)
shell: pwsh
+ env:
+ # Empty for anything but a release from this repo: secrets are not
+ # exposed to forks, so a fork build carries no DSN and cannot report.
+ SATELLITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
$env:VCPKG_ROOT = $env:VCPKG_INSTALLATION_ROOT
$generator = ./scripts/windows-vs-generator.ps1
$disp = ("${{ inputs.tag || github.ref_name }}") -replace '^v',''
- cmake --preset windows-msvc -G $generator -DSATELLITE_RELEASE_VERSION="$disp"
+ cmake --preset windows-msvc -G $generator -DSATELLITE_RELEASE_VERSION="$disp" -DSATELLITE_SENTRY_DSN="$env:SATELLITE_SENTRY_DSN"
cmake --build --preset windows-msvc --target satellite
if (-not (Test-Path satellite.exe)) { throw "MSVC build did not produce satellite.exe at repo root" }
@@ -285,9 +289,13 @@ jobs:
run: brew install cmake pkg-config libsodium opus
- name: Build satellite.app (stub)
+ env:
+ # Empty for anything but a release from this repo: secrets are not
+ # exposed to forks, so a fork build carries no DSN and cannot report.
+ SATELLITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
tag="${{ inputs.tag || github.ref_name }}"
- cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${tag#v}"
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${tag#v}" -DSATELLITE_SENTRY_DSN="${SATELLITE_SENTRY_DSN}"
cmake --build build --config Release --target satellite -j "$(sysctl -n hw.ncpu)"
- name: Sign + notarize (if Developer ID configured)
@@ -407,9 +415,13 @@ jobs:
ref: ${{ inputs.tag || github.ref }}
- name: Build satellite
+ env:
+ # Empty for anything but a release from this repo: secrets are not
+ # exposed to forks, so a fork build carries no DSN and cannot report.
+ SATELLITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
tag="${{ inputs.tag || github.ref_name }}"
- cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${tag#v}"
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${tag#v}" -DSATELLITE_SENTRY_DSN="${SATELLITE_SENTRY_DSN}"
cmake --build build --config Release --target satellite -j "$(nproc)"
- name: Package (.deb via CPack)
@@ -537,9 +549,13 @@ jobs:
ref: ${{ inputs.tag || github.ref }}
- name: Build satellite
+ env:
+ # Empty for anything but a release from this repo: secrets are not
+ # exposed to forks, so a fork build carries no DSN and cannot report.
+ SATELLITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
tag="${{ inputs.tag || github.ref_name }}"
- cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${tag#v}"
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${tag#v}" -DSATELLITE_SENTRY_DSN="${SATELLITE_SENTRY_DSN}"
cmake --build build --config Release --target satellite -j "$(nproc)"
- name: Package (.rpm via CPack)
@@ -644,6 +660,10 @@ jobs:
# copy) lives in scripts/build-appimage.sh so a local AppImage and the
# released one come off the same path (the dish-linux model).
- name: Build AppImage
+ env:
+ # Empty for anything but a release from this repo: secrets are not
+ # exposed to forks, so a fork build carries no DSN and cannot report.
+ SATELLITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
tag="${{ inputs.tag || github.ref_name }}"
SATELLITE_VERSION="${tag#v}" bash scripts/build-appimage.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 962fdd3..71b3b71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -249,6 +249,44 @@ MINGW64; the Windows lane now also carries warnings-as-errors like Linux and
macOS; `vcpkg.json` sat at 1.0.0 while `/VERSION` said 1.1.0 (now checked by
version-consistency.yml).
+Opt-in crash reporting. Satellite has never transmitted anything, and on
+Linux and macOS it had no crash recorder at all: a segfault died with whatever
+the distro's core-dump collector happened to catch. Windows wrote a local
+minidump nobody was told about. This adds Sentry behind two independent gates,
+because one is not enough. The operator's switch (Settings, Diagnostics) is
+off by default and stays off through an upgrade: an install that never saw the
+ask must not start transmitting on its owner's behalf, which is why the config
+default is `false` rather than matching the Dish clients' default-on. Turning
+it off disarms the SDK immediately rather than at the next restart, since
+withdrawing consent has to stop the next crash and not the one after it.
+
+The second gate is the build. `SATELLITE_SENTRY_DSN` is empty in CMake and is
+only ever filled in by `release.yml` from a repository secret, so a local
+build, a PR build and a build from a fork (secrets are not exposed to forks)
+carry no DSN and cannot transmit no matter what the switch says. The Sentry
+environment is derived from `SATELLITE_RELEASE_VERSION`, which only the release
+workflow sets, so `production` is not a label a developer build reaches by
+accident -- and because it is only a label (`scripts/build-appimage.sh` sets
+that variable when run by hand), the DSN is the gate that actually holds.
+The release string uses the display version, so a `-dev` build cannot file
+itself against a real release and mix unsymbolicated frames into genuine data.
+`$SENTRY_DSN` still works as a developer escape hatch, and still respects the
+opt-in.
+
+Nothing about the local artifacts changes. On Windows the existing
+`dumps\*.dmp` writer keeps running, and `dumpFilter` now chains to whatever
+top-level filter was installed before it instead of swallowing the exception,
+so the local dump and the Sentry report both see the crash rather than
+whichever recorder armed last winning outright. Automatic session tracking is
+turned off, because it defaults to on and a server meant to run unattended for
+weeks should not report every start and stop to anyone; the crash is the
+payload. PII is off too, but by not touching it: sentry-native does not send it
+by default, and the setter that would change that exists only on Nintendo
+Switch.
+The status payload reports the opt-in and whether it actually armed as separate
+fields, so a build with no DSN says so instead of claiming reports are going
+somewhere they are not.
+
## 1.1.0
No protocol changes. Distribution release: every shipping platform now also
diff --git a/CMakeLists.txt b/CMakeLists.txt
index cc28662..f623d5d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -38,6 +38,46 @@ if(WIN32)
add_compile_definitions(NOMINMAX)
endif()
+# ── Crash reporting (Sentry) ──────────────────────────────────────────────
+#
+# Two independent gates, on purpose.
+#
+# The ENVIRONMENT is a label. It is derived from SATELLITE_RELEASE_VERSION,
+# which only .github/workflows/release.yml sets, so "production" is not a name
+# a developer build reaches by accident. It is still only a label: anyone can
+# pass -DSATELLITE_RELEASE_VERSION, and scripts/build-appimage.sh does exactly
+# that when run by hand.
+#
+# The DSN is the real gate. It is empty here and is only ever filled in by
+# release.yml from the SENTRY_DSN repository secret, so a local build, a PR
+# build and a build from a fork (secrets are not exposed to forks) carry no
+# DSN and physically cannot transmit, whatever label they wear. Labels drift;
+# a missing DSN cannot.
+#
+# RELEASE uses the DISPLAY version, not SATELLITE_VERSION: the latter is
+# stripped to bare MAJOR.MINOR.PATCH above, so a dev build would file itself
+# against the real release and mix unsymbolicated frames into genuine data.
+# DISPLAY carries the "-dev" suffix that keeps the two apart.
+if(DEFINED SATELLITE_RELEASE_VERSION AND NOT "${SATELLITE_RELEASE_VERSION}" STREQUAL "")
+ set(SATELLITE_SENTRY_ENVIRONMENT "production")
+else()
+ set(SATELLITE_SENTRY_ENVIRONMENT "development")
+endif()
+
+set(SATELLITE_SENTRY_DSN "" CACHE STRING
+ "Sentry DSN. Empty (the default) disables crash reporting at compile time.")
+
+# Optional dependency, exactly like the tray: absent means the facade in
+# core/crash_reporting.cpp compiles to inert stubs and everything else still
+# builds. Keeps a source checkout buildable with no extra packages.
+find_package(sentry CONFIG QUIET)
+if(sentry_FOUND)
+ message(STATUS "satellite: Sentry SDK found; crash reporting compiled in "
+ "(environment: ${SATELLITE_SENTRY_ENVIRONMENT})")
+else()
+ message(STATUS "satellite: Sentry SDK not found; crash reporting stubbed out")
+endif()
+
# Vendored single-header libraries (nlohmann/json, cpp-httplib) under lib/.
# SYSTEM keeps their internal warnings out of -Wall/-Wextra//W4, and every
# target (including pure-core tests) can include them.
@@ -51,6 +91,7 @@ set(SATELLITE_CORE_SOURCES
src/core/update_service.cpp
src/core/github_release.cpp
src/core/network_info.cpp
+ src/core/crash_reporting.cpp
)
# Currently-shared infrastructure (to be ported to POSIX in Stage 3).
@@ -78,6 +119,7 @@ set(SATELLITE_NET_SOURCES
set(SATELLITE_ADAPTER_SOURCES
src/adapters/log_adapter.cpp
src/adapters/client_adapter.cpp
+ src/adapters/crash_adapter.cpp
src/adapters/audio/opus_codec.cpp
)
@@ -721,6 +763,18 @@ if(TARGET satellite)
target_compile_definitions(satellite PRIVATE
SATELLITE_VERSION_RESOLVED="${SATELLITE_VERSION_DISPLAY}")
+ # Crash reporting identity. Only the shipped binary carries these; the test
+ # targets compile core/crash_reporting.cpp against the header's fallbacks,
+ # which resolve to an empty DSN and so can never transmit from a test run.
+ target_compile_definitions(satellite PRIVATE
+ SATELLITE_SENTRY_DSN="${SATELLITE_SENTRY_DSN}"
+ SATELLITE_SENTRY_ENVIRONMENT="${SATELLITE_SENTRY_ENVIRONMENT}"
+ SATELLITE_SENTRY_RELEASE="satellite@${SATELLITE_VERSION_DISPLAY}")
+ if(sentry_FOUND)
+ target_compile_definitions(satellite PRIVATE SATELLITE_HAS_SENTRY=1)
+ target_link_libraries(satellite PRIVATE sentry::sentry)
+ endif()
+
include(CheckIPOSupported)
check_ipo_supported(RESULT SATELLITE_IPO_OK OUTPUT SATELLITE_IPO_MSG LANGUAGES CXX)
if(SATELLITE_IPO_OK)
@@ -881,6 +935,8 @@ satellite_add_pure_test(test_github_release tests/test_github_release.cpp src/co
satellite_add_pure_test(test_update_service tests/test_update_service.cpp src/core/update_service.cpp)
satellite_add_pure_test(test_json tests/test_json.cpp)
satellite_add_pure_test(test_config_json tests/test_config_json.cpp)
+satellite_add_pure_test(test_crash_reporting tests/test_crash_reporting.cpp
+ src/core/crash_reporting.cpp src/adapters/crash_adapter.cpp)
satellite_add_pure_test(test_network_info tests/test_network_info.cpp src/core/network_info.cpp)
satellite_add_pure_test(test_origin_guard tests/test_origin_guard.cpp)
satellite_add_pure_test(test_status_json tests/test_status_json.cpp)
@@ -975,6 +1031,11 @@ if(APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux")
src/net/local_iface.cpp
src/core/config_io.cpp
${SATELLITE_CORE_SOURCES}
+ # routes_admin.cpp reads crash::active() and flips crash::setEnabled(),
+ # so the route tests need the adapter as well as the pure policy that
+ # rides in SATELLITE_CORE_SOURCES. It compiles inert here: the test
+ # targets never define SATELLITE_HAS_SENTRY.
+ src/adapters/crash_adapter.cpp
)
if(APPLE)
set(SATELLITE_ROUTE_TEST_PLATFORM_SOURCES
diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh
index e5174b4..60faf56 100755
--- a/scripts/build-appimage.sh
+++ b/scripts/build-appimage.sh
@@ -37,7 +37,10 @@ appdir="${tmp_dir}/Satellite.AppDir"
echo "==> satellite ${version} AppImage (x86_64)"
-cmake -S . -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${version}"
+# SATELLITE_SENTRY_DSN is empty unless release.yml exported it from the
+# repository secret, so a hand-run of this script still produces a build
+# that cannot transmit, even though it stamps a release version.
+cmake -S . -B "${build_dir}" -DCMAKE_BUILD_TYPE=Release -DSATELLITE_RELEASE_VERSION="${version}" -DSATELLITE_SENTRY_DSN="${SATELLITE_SENTRY_DSN:-}"
cmake --build "${build_dir}" --config Release --target satellite -j "$(nproc)"
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O linuxdeploy
diff --git a/src/adapters/crash_adapter.cpp b/src/adapters/crash_adapter.cpp
new file mode 100644
index 0000000..deb4dcd
--- /dev/null
+++ b/src/adapters/crash_adapter.cpp
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#include "adapters/crash_adapter.h"
+
+#include "core/crash_reporting.h"
+
+#ifdef SATELLITE_HAS_SENTRY
+#include
+#endif
+
+namespace satellite::crash {
+
+namespace {
+bool g_active = false;
+// Remembered from init() so a live flip can re-arm without the admin route
+// having to know where satellite keeps its state.
+std::string g_databaseDir;
+} // namespace
+
+bool sdkAvailable() {
+#ifdef SATELLITE_HAS_SENTRY
+ return true;
+#else
+ return false;
+#endif
+}
+
+void init([[maybe_unused]] bool userEnabled, [[maybe_unused]] const std::string& databaseDir) {
+ // Remembered even when the policy says no, so a later opt-in can arm
+ // without being handed the path again.
+ if (!databaseDir.empty()) { g_databaseDir = databaseDir; }
+
+ if (g_active) { return; }
+
+ const std::string envOverride = envDsn();
+ if (!shouldArm(compiledDsn(), envOverride.c_str(), userEnabled)) { return; }
+
+#ifdef SATELLITE_HAS_SENTRY
+ sentry_options_t* options = sentry_options_new();
+
+ // Leave the DSN unset when only $SENTRY_DSN is present: the SDK reads the
+ // environment itself, and setting an empty string here would override it.
+ if (compiledDsn()[0] != '\0') { sentry_options_set_dsn(options, compiledDsn()); }
+
+ // Not the working directory. A tray app launched from Explorer, or the
+ // service, has no cwd worth writing run state into.
+ sentry_options_set_database_path(options, g_databaseDir.c_str());
+
+ sentry_options_set_release(options, release());
+ sentry_options_set_environment(options, environment());
+
+ // Off in shipped builds: the SDK's debug channel is noisy and satellite
+ // already has its own log.
+ sentry_options_set_debug(options, 0);
+
+ // The crash itself is the payload. Session tracking would report every
+ // start and stop of a server meant to run unattended for weeks, which is
+ // telemetry the operator did not agree to when they ticked a box labelled
+ // "crash reports".
+ sentry_options_set_auto_session_tracking(options, 0);
+
+ // No sentry_options_set_send_default_pii() call here on purpose: in
+ // sentry-native that setter exists only under SENTRY_PLATFORM_NX, and its
+ // own documentation states that not sending PII is already the default
+ // everywhere ("If false (the default), the SDK won't add PII or other
+ // sensitive data to the payload"). Calling it would not compile on any
+ // platform satellite ships to, and would not change behaviour if it did.
+
+ if (sentry_init(options) == 0) { g_active = true; }
+#endif
+}
+
+void setEnabled(bool userEnabled) {
+ if (!userEnabled) {
+ shutdown();
+ return;
+ }
+ init(true, g_databaseDir);
+}
+
+void shutdown() {
+ if (!g_active) { return; }
+ g_active = false;
+#ifdef SATELLITE_HAS_SENTRY
+ sentry_close();
+#endif
+}
+
+bool active() { return g_active; }
+
+} // namespace satellite::crash
diff --git a/src/adapters/crash_adapter.h b/src/adapters/crash_adapter.h
new file mode 100644
index 0000000..bb1ba6e
--- /dev/null
+++ b/src/adapters/crash_adapter.h
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+//
+// Binds the crash-reporting policy in core/crash_reporting.h to the Sentry
+// native SDK. The third-party header lives here rather than in src/core
+// because core is std-only by gate (scripts/check_core_purity.sh) and has to
+// stay compilable on every platform with no external surface.
+//
+// Every entry point is inert when the SDK was not found at configure time
+// (SATELLITE_HAS_SENTRY undefined), so a source checkout with no Sentry
+// package still builds and behaves.
+
+#pragma once
+
+#include
+
+namespace satellite::crash {
+
+// True when this build was linked against the Sentry SDK at all. False means
+// everything below is a no-op.
+bool sdkAvailable();
+
+// Arms the SDK if the policy in core/crash_reporting.h says so. `databaseDir`
+// is where Sentry keeps its run state and any pending envelope; it must be a
+// writable absolute path that survives restarts, NOT the working directory (a
+// tray app launched from Explorer, or the service, has no predictable cwd).
+//
+// Idempotent. Safe to call when the SDK is absent or the policy says no.
+void init(bool userEnabled, const std::string& databaseDir);
+
+// Applies a live flip of the operator's opt-in, reusing the database path the
+// last init() was given.
+//
+// Disarming is immediate and deliberate: withdrawing consent has to stop the
+// next crash from being sent, not the one after that. Arming is also
+// immediate, so the switch is not a lie in either direction.
+void setEnabled(bool userEnabled);
+
+// Flushes pending events and closes the SDK. Safe when init() never armed.
+void shutdown();
+
+// True only when init() actually armed the SDK. Drives what the admin UI is
+// allowed to claim: a build with no DSN must not show "reports are being sent".
+bool active();
+
+} // namespace satellite::crash
diff --git a/src/core/config_json.h b/src/core/config_json.h
index 1004abe..e078cce 100644
--- a/src/core/config_json.h
+++ b/src/core/config_json.h
@@ -29,6 +29,7 @@ inline std::string serializeConfig(const Config& cfg) {
j["controllerAudioMic"] = cfg.controllerAudioMic;
j["controllerAudioSpeaker"] = cfg.controllerAudioSpeaker;
j["controllerAudioKeepDefaultDevice"] = cfg.controllerAudioKeepDefaultDevice;
+ j["crashReporting"] = cfg.crashReporting;
JsonOut devices = JsonOut::array();
for (const auto& d : cfg.pairedDevices) {
@@ -85,6 +86,10 @@ inline void parseConfigInto(const std::string& text, Config& cfg) {
cfg.controllerAudioSpeaker = jsonBool(j, "controllerAudioSpeaker", cfg.controllerAudioSpeaker);
cfg.controllerAudioKeepDefaultDevice =
jsonBool(j, "controllerAudioKeepDefaultDevice", cfg.controllerAudioKeepDefaultDevice);
+ // Absent in every config written before crash reporting existed, and the
+ // default it falls back to is false. That is the point: an upgrade must
+ // not start transmitting on behalf of an operator who never saw the ask.
+ cfg.crashReporting = jsonBool(j, "crashReporting", cfg.crashReporting);
auto it = j.find("pairedDevices");
if (it != j.end() && it->is_array()) {
diff --git a/src/core/crash_reporting.cpp b/src/core/crash_reporting.cpp
new file mode 100644
index 0000000..3252d2e
--- /dev/null
+++ b/src/core/crash_reporting.cpp
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#include "core/crash_reporting.h"
+
+#include
+#include
+
+// Injected by CMake. The fallbacks keep this translation unit compilable on
+// its own, which is exactly the state the pure policy test builds it in: an
+// empty DSN, so a test run can never transmit.
+#ifndef SATELLITE_SENTRY_DSN
+#define SATELLITE_SENTRY_DSN ""
+#endif
+#ifndef SATELLITE_SENTRY_ENVIRONMENT
+#define SATELLITE_SENTRY_ENVIRONMENT "development"
+#endif
+#ifndef SATELLITE_SENTRY_RELEASE
+#define SATELLITE_SENTRY_RELEASE "satellite@unknown"
+#endif
+
+namespace satellite::crash {
+
+const char* compiledDsn() { return SATELLITE_SENTRY_DSN; }
+const char* environment() { return SATELLITE_SENTRY_ENVIRONMENT; }
+const char* release() { return SATELLITE_SENTRY_RELEASE; }
+
+std::string envDsn() {
+#ifdef _MSC_VER
+ // The hardened MSVC lane builds with warnings as errors, and std::getenv
+ // trips C4996 there. _dupenv_s is the sanctioned spelling; same contract.
+ char* raw = nullptr;
+ std::size_t len = 0;
+ if (_dupenv_s(&raw, &len, "SENTRY_DSN") != 0 || raw == nullptr) { return {}; }
+ std::string value(raw);
+ std::free(raw);
+ return value;
+#else
+ const char* raw = std::getenv("SENTRY_DSN");
+ return raw != nullptr ? std::string(raw) : std::string();
+#endif
+}
+
+bool shouldArm(const char* compiled, const char* envOverride, bool userEnabled) {
+ // The opt-in is checked first and is never bypassed. $SENTRY_DSN lets a
+ // developer aim a build at a scratch project; it is not a way to report
+ // from a machine whose operator declined.
+ if (!userEnabled) { return false; }
+ const bool haveCompiled = compiled != nullptr && compiled[0] != '\0';
+ const bool haveOverride = envOverride != nullptr && envOverride[0] != '\0';
+ return haveCompiled || haveOverride;
+}
+
+std::string databaseDirFor(const std::string& configFilePath) {
+ const std::size_t cut = configFilePath.find_last_of("/\\");
+ if (cut == std::string::npos) {
+ // No directory component at all: keep it beside whatever the caller
+ // named rather than inventing an absolute path.
+ return "sentry";
+ }
+ // Reuse the separator already in the path so a Windows caller does not get
+ // a mixed one back.
+ return configFilePath.substr(0, cut + 1) + "sentry";
+}
+
+} // namespace satellite::crash
diff --git a/src/core/crash_reporting.h b/src/core/crash_reporting.h
new file mode 100644
index 0000000..adc6f88
--- /dev/null
+++ b/src/core/crash_reporting.h
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+//
+// Crash reporting: the policy, with no SDK anywhere near it.
+//
+// Satellite transmits nothing unless BOTH of these are true:
+//
+// 1. The build carries a DSN. SATELLITE_SENTRY_DSN is empty by default and
+// is only injected by release.yml from a repository secret, so a local
+// build, a PR build and a fork build all physically cannot report,
+// whatever environment string they claim. A label can be passed on the
+// command line; a missing DSN cannot be argued with.
+// 2. The operator opted in. Config::crashReporting defaults to false, so an
+// existing install that never saw the switch stays silent.
+//
+// The environment string separates the two worlds in Sentry. It is derived
+// from SATELLITE_RELEASE_VERSION, which only release.yml sets, so "production"
+// cannot be reached by a developer build even by accident.
+//
+// Everything here is std-only by design: this is the half the core purity gate
+// allows, and the half worth testing. The Sentry binding lives in
+// adapters/crash_adapter.h, which is where a third-party header belongs.
+
+#pragma once
+
+#include
+
+namespace satellite::crash {
+
+// Compiled-in identity. These come from CMake, never from runtime config.
+const char* compiledDsn();
+const char* environment(); // "production" | "development"
+const char* release(); // "satellite@"
+
+// $SENTRY_DSN, or empty when unset: the deliberate escape hatch for pointing a
+// local build at a scratch project. Read through _dupenv_s on MSVC, whose
+// warnings-as-errors lane rejects std::getenv outright.
+std::string envDsn();
+
+// The pure policy. `compiled` is the baked-in DSN (may be empty), `envOverride`
+// is $SENTRY_DSN (may be null or empty), `userEnabled` is the operator's opt-in.
+//
+// Reporting arms only when some DSN exists AND the operator said yes. The
+// escape hatch deliberately still respects the opt-in: a developer pointing a
+// build at their own project is not a reason to bypass a user's choice on a
+// machine that is not theirs.
+bool shouldArm(const char* compiled, const char* envOverride, bool userEnabled);
+
+// Derives the SDK's state directory from the config file's path: the config
+// file's own directory, plus a "sentry" subdirectory. Pure and separator-aware
+// so it can be checked against explicit inputs.
+//
+// Windows passes lifecycle::sentryDir() instead, so crash state lands beside
+// the existing dumps\ and logs\ in LocalAppData rather than in the roaming
+// profile.
+std::string databaseDirFor(const std::string& configFilePath);
+
+} // namespace satellite::crash
diff --git a/src/core/types.h b/src/core/types.h
index 3d7de20..1135514 100644
--- a/src/core/types.h
+++ b/src/core/types.h
@@ -912,6 +912,14 @@ struct Config {
// by default -- which is what makes controller audio feel like it forwards
// "everything". On means put the previous default back afterwards.
bool controllerAudioKeepDefaultDevice = true;
+
+ // Crash reporting to Sentry. Off by default, and deliberately not matched
+ // to the Dish clients' default-on: satellite transmitted nothing at all
+ // before this existed, so every install upgrading into it has an operator
+ // who was never asked. Defaulting on would answer for them. A release
+ // build additionally needs a DSN compiled in before this switch can do
+ // anything -- see core/crash_reporting.h.
+ bool crashReporting = false;
};
// The two wire gates above, sampled together. One read means one lock rather
diff --git a/src/net/routes_admin.cpp b/src/net/routes_admin.cpp
index dfa21a9..309036e 100644
--- a/src/net/routes_admin.cpp
+++ b/src/net/routes_admin.cpp
@@ -11,6 +11,7 @@
#include "config.h"
#include "pairing.h"
#include "pairing_service.h"
+#include "adapters/crash_adapter.h"
#include "core/json.h"
#include "core/session_service.h"
#include "core/update_service.h"
@@ -35,6 +36,8 @@ using satellite::jsonTryBool;
using satellite::jsonTryInt;
using satellite::StatusFields;
+namespace crash = satellite::crash;
+
// Keys must stay in sync with the web/ JS that consumes them.
static std::string buildUpdateJson(const UpdateStatusSnapshot& s) {
JsonOut j;
@@ -261,7 +264,9 @@ void registerAdminRoutes(httplib::Server& server, SessionService& svc) {
f.controllerAudioMic = g_config.controllerAudioMic;
f.controllerAudioSpeaker = g_config.controllerAudioSpeaker;
f.controllerAudioKeepDefaultDevice = g_config.controllerAudioKeepDefaultDevice;
+ f.crashReporting = g_config.crashReporting;
}
+ f.crashReportingActive = crash::active();
f.listening = g_listening.load();
f.packets = static_cast(g_packetCount.load());
f.senderIP = senderIP;
@@ -364,6 +369,14 @@ void registerAdminRoutes(httplib::Server& server, SessionService& svc) {
g_config.controllerAudioKeepDefaultDevice = keepDefaultVal;
}
+ // Consent, so it reaches the SDK now rather than at the next restart.
+ // Withdrawing it has to stop the very next crash from being sent.
+ bool crashReportingVal = false;
+ if (jsonTryBool(body, "crashReporting", crashReportingVal)) {
+ g_config.crashReporting = crashReportingVal;
+ crash::setEnabled(crashReportingVal);
+ }
+
if (body.contains("networkInterface")) {
g_config.networkInterface = jsonStr(body, "networkInterface");
}
diff --git a/src/net/status_json.h b/src/net/status_json.h
index 20476ca..da18154 100644
--- a/src/net/status_json.h
+++ b/src/net/status_json.h
@@ -21,6 +21,11 @@ struct StatusFields {
bool controllerAudioMic = true;
bool controllerAudioSpeaker = true;
bool controllerAudioKeepDefaultDevice = true;
+ // The operator's opt-in, and whether it actually amounts to anything. They
+ // differ on every build without a DSN compiled in, and the UI has to say
+ // so rather than imply reports are going somewhere they are not.
+ bool crashReporting = false;
+ bool crashReportingActive = false;
bool mdnsResponderActive = false;
bool backendAvailable = false;
uint64_t submitOk = 0;
@@ -57,6 +62,8 @@ inline std::string buildStatusJson(const StatusFields& f) {
j["controllerAudioMic"] = f.controllerAudioMic;
j["controllerAudioSpeaker"] = f.controllerAudioSpeaker;
j["controllerAudioKeepDefaultDevice"] = f.controllerAudioKeepDefaultDevice;
+ j["crashReporting"] = f.crashReporting;
+ j["crashReportingActive"] = f.crashReportingActive;
j["mdnsResponderActive"] = f.mdnsResponderActive;
j["backendAvailable"] = f.backendAvailable;
j["backend"] = f.backend;
diff --git a/src/platform/linux/main.cpp b/src/platform/linux/main.cpp
index 09a93a2..a31a117 100644
--- a/src/platform/linux/main.cpp
+++ b/src/platform/linux/main.cpp
@@ -34,6 +34,11 @@
#include
#include
+#include "adapters/crash_adapter.h"
+#include "core/crash_reporting.h"
+
+namespace crash = satellite::crash;
+
#ifdef SATELLITE_HAS_TRAY
#include
#include
@@ -128,6 +133,11 @@ int main(int argc, const char* argv[]) {
g_config = loadConfig();
g_config.autoStart = getAutoStart();
+ // Nothing else claims the fatal signals on Linux, so this is the only
+ // crash recorder satellite has here. It still arms only behind the
+ // operator's opt-in and a DSN this build actually carries.
+ crash::init(g_config.crashReporting, crash::databaseDirFor(configPath()));
+
GamepadAdapter gamepadAdapter;
ClientAdapter clientAdapter;
LogAdapter logAdapter;
@@ -229,6 +239,11 @@ int main(int argc, const char* argv[]) {
svc.closeAllSessions();
saveConfig(g_config);
+
+ // Flush before exit; a pending envelope is lost if the transport never
+ // gets to run.
+ crash::shutdown();
+
netShutdown();
return 0;
}
diff --git a/src/platform/macos/main.mm b/src/platform/macos/main.mm
index 0e56fa9..0fdc56b 100644
--- a/src/platform/macos/main.mm
+++ b/src/platform/macos/main.mm
@@ -20,6 +20,10 @@
#include "core/session_service.h"
#include "core/update_service.h"
+#include "adapters/crash_adapter.h"
+#include "core/crash_reporting.h"
+
+namespace crash = satellite::crash;
#include
@@ -68,6 +72,10 @@ int main(int argc, const char* argv[]) {
g_config = loadConfig();
g_config.autoStart = getAutoStart();
+ // As on Linux: the only crash recorder here, and still gated on the
+ // operator's opt-in plus a DSN this build actually carries.
+ crash::init(g_config.crashReporting, crash::databaseDirFor(configPath()));
+
MacHidGamepadAdapter gamepadAdapter;
ClientAdapter clientAdapter;
LogAdapter logAdapter;
@@ -145,6 +153,10 @@ SessionService svc(gamepadAdapter, clientAdapter, logAdapter, deriveSessionKey,
svc.closeAllSessions();
saveConfig(g_config);
+
+ // Flush before exit; a pending envelope is lost otherwise.
+ crash::shutdown();
+
netShutdown();
}
return 0;
diff --git a/src/platform/windows/app_lifecycle.cpp b/src/platform/windows/app_lifecycle.cpp
index 32ba8f3..104276c 100644
--- a/src/platform/windows/app_lifecycle.cpp
+++ b/src/platform/windows/app_lifecycle.cpp
@@ -35,6 +35,11 @@ constexpr const char* kRunValueName = "Satellite";
// Retention cap: a leaky build can drop a 5-20MB .dmp per minute.
constexpr size_t kMaxDumpFiles = 10;
+// Whoever held the top-level filter before us. Sentry installs its own during
+// sentry_init(), so swallowing the exception here would mean a crash is only
+// ever recorded in one of the two places. We chain instead.
+LPTOP_LEVEL_EXCEPTION_FILTER g_prevFilter = nullptr;
+
// Rolls on size or date change, whichever trips first.
constexpr size_t kMaxLogFileBytes = 5 * 1024 * 1024;
constexpr int kLogRetentionDays = 7;
@@ -137,8 +142,16 @@ void deleteOlderThan(const std::wstring& dir, const wchar_t* ext, int days) {
FindClose(h);
}
+// Passes the exception on to whoever held the filter before us, or to the OS
+// when nobody did. Every exit from dumpFilter goes through here so a later
+// early return cannot silently drop the chain.
+LONG chainOrDefault(EXCEPTION_POINTERS* ep) {
+ if (g_prevFilter != nullptr) return g_prevFilter(ep);
+ return EXCEPTION_EXECUTE_HANDLER;
+}
+
LONG WINAPI dumpFilter(EXCEPTION_POINTERS* ep) {
- if (g_dumpDirW[0] == L'\0') return EXCEPTION_EXECUTE_HANDLER;
+ if (g_dumpDirW[0] == L'\0') return chainOrDefault(ep);
SYSTEMTIME st;
GetLocalTime(&st);
@@ -146,7 +159,7 @@ LONG WINAPI dumpFilter(EXCEPTION_POINTERS* ep) {
if (FAILED(StringCchPrintfW(path, ARRAYSIZE(path),
L"%s\\satellite-%04u%02u%02u-%02u%02u%02u.dmp", g_dumpDirW,
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond))) {
- return EXCEPTION_EXECUTE_HANDLER;
+ return chainOrDefault(ep);
}
HANDLE f =
@@ -162,8 +175,9 @@ LONG WINAPI dumpFilter(EXCEPTION_POINTERS* ep) {
CloseHandle(f);
}
- // Hand off to the default handler so WER still runs.
- return EXCEPTION_EXECUTE_HANDLER;
+ // Hand off so WER, and Sentry's filter when crash reporting is armed,
+ // still run.
+ return chainOrDefault(ep);
}
const char* levelStr(LogLevel l) {
@@ -289,6 +303,7 @@ void loggerLoop() {
std::string dumpDir() { return ensureSubdir(L"dumps"); }
std::string logDir() { return ensureSubdir(L"logs"); }
+std::string sentryDir() { return ensureSubdir(L"sentry"); }
bool acquireSingleInstance(const char* appTitle) {
// Local\ namespace = per-session, so RDP sessions/fast user switching get
@@ -318,12 +333,23 @@ void installCrashHandler() {
std::wstring dumps = ensureSubdirW(L"dumps");
StringCchCopyW(g_dumpDirW, ARRAYSIZE(g_dumpDirW), dumps.c_str());
- SetUnhandledExceptionFilter(dumpFilter);
+ // Keep whatever was installed before us. crash::init() runs first in
+ // WinMain, so when reporting is armed this is Sentry's filter and both it
+ // and the local dumps\ artifact see the crash.
+ g_prevFilter = SetUnhandledExceptionFilter(dumpFilter);
// One-shot trim now, since rotation otherwise waits for the next crash.
retainNewestN(dumps, L".dmp", kMaxDumpFiles);
}
+void rearmCrashFilterChain() {
+ LPTOP_LEVEL_EXCEPTION_FILTER prev = SetUnhandledExceptionFilter(dumpFilter);
+ // Guard against chaining to ourselves, which would recurse until the stack
+ // is gone. prev == dumpFilter means nothing installed after us and there is
+ // nothing new to chain to.
+ if (prev != dumpFilter) { g_prevFilter = prev; }
+}
+
void registerForRestart() {
// /restart lets a recovery relaunch (Update reboot, Restart Manager, etc.)
// be distinguished from a user double-click. GetProcAddress: Vista+.
diff --git a/src/platform/windows/app_lifecycle.h b/src/platform/windows/app_lifecycle.h
index 7fcda65..be61f3e 100644
--- a/src/platform/windows/app_lifecycle.h
+++ b/src/platform/windows/app_lifecycle.h
@@ -11,6 +11,11 @@ std::string dumpDir();
// %LOCALAPPDATA%\TinkerNorth\Satellite\logs, created on demand.
std::string logDir();
+// %LOCALAPPDATA%\TinkerNorth\Satellite\sentry, created on demand. Local rather
+// than roaming: crash-reporter run state is about this machine and has no
+// business following the user's profile between them.
+std::string sentryDir();
+
// True if we are the first instance. False if another is already running, in
// which case we ping its HWND_MESSAGE window (titled `appTitle`) and the caller
// should exit without showing UI.
@@ -19,6 +24,17 @@ bool acquireSingleInstance(const char* appTitle);
// Idempotent. Caps retained dumps so a leaky build can't fill the disk.
void installCrashHandler();
+// Re-asserts satellite's filter over one installed after it, capturing that
+// one as the chain target.
+//
+// Needed because the two crash recorders arm at different times: the local
+// minidump goes up in the first lines of WinMain, before any file I/O, while
+// Sentry can only arm once the config has been read and the operator's opt-in
+// is known. Whoever installs last wins outright, so without this call the
+// local dumps\ artifact would be silently replaced. Call once after
+// crash::init(). Safe to call when nothing else installed a filter.
+void rearmCrashFilterChain();
+
// Relaunch after an OS-initiated reboot; passes /restart so we can tell a
// recovery launch from a user double-click.
void registerForRestart();
diff --git a/src/platform/windows/main.cpp b/src/platform/windows/main.cpp
index ab9bf98..76524e1 100644
--- a/src/platform/windows/main.cpp
+++ b/src/platform/windows/main.cpp
@@ -20,6 +20,7 @@
#include "updater_adapter.h"
#include "adapters/client_adapter.h"
#include "adapters/log_adapter.h"
+#include "adapters/crash_adapter.h"
#include "adapters/audio/opus_codec.h"
#include "core/gamepad_mux.h"
@@ -34,6 +35,8 @@
#include
#include
+namespace crash = satellite::crash;
+
namespace {
// TaskDialogIndirect instead of MessageBox: modern styling, icon, hyperlinks.
@@ -170,6 +173,13 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR lpCmdLine, int) {
g_config = loadConfig();
if (firstRun) g_config.autoStart = getAutoStart();
+ // Crash reporting arms here rather than beside installCrashHandler(),
+ // because it needs the operator's opt-in and that only exists once the
+ // config has been read. Sentry installs its own top-level filter, so
+ // satellite's goes back on top afterwards and chains to it.
+ crash::init(g_config.crashReporting, lifecycle::sentryDir());
+ lifecycle::rearmCrashFilterChain();
+
// Constructive only: writes HKCU\Run when autostart is on, never deletes,
// so a portable build can't wipe an installed copy's Run entry.
lifecycle::reconcileAutoStart();
@@ -314,6 +324,10 @@ int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR lpCmdLine, int) {
lifecycle::stopFileLogger();
+ // Flush before the process winds down; a pending envelope is lost if the
+ // transport never gets to run.
+ crash::shutdown();
+
removeTrayIcon();
saveConfig(g_config);
timeEndPeriod(1);
diff --git a/tests/test_crash_reporting.cpp b/tests/test_crash_reporting.cpp
new file mode 100644
index 0000000..1765b92
--- /dev/null
+++ b/tests/test_crash_reporting.cpp
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+//
+// The arming policy is the whole privacy property of crash reporting, so it is
+// tested as a pure function rather than inferred from an integration run. Two
+// independent gates have to hold: a DSN must exist, and the operator must have
+// said yes. Neither one alone is enough, in either direction.
+
+#include "adapters/crash_adapter.h"
+#include "core/config_json.h"
+#include "core/crash_reporting.h"
+
+#include
+#include
+
+#include "test_util.h"
+
+using namespace satellite;
+
+static const char* kDsn = "https://key@o1.ingest.de.sentry.io/2";
+
+static void test_no_dsn_never_arms() {
+ TEST("shouldArm: a build with no DSN cannot report, even when opted in");
+ // This is every local build, every PR build and every build from a fork,
+ // because only release.yml injects the secret. Opting in is not a way
+ // around it.
+ EXPECT(!crash::shouldArm("", nullptr, true));
+ EXPECT(!crash::shouldArm(nullptr, nullptr, true));
+ EXPECT(!crash::shouldArm("", "", true));
+}
+
+static void test_opt_out_never_arms() {
+ TEST("shouldArm: a declined opt-in beats any DSN");
+ EXPECT(!crash::shouldArm(kDsn, nullptr, false));
+ // Including the developer escape hatch: aiming a build at your own project
+ // is not a reason to transmit from a machine that is not yours.
+ EXPECT(!crash::shouldArm("", kDsn, false));
+ EXPECT(!crash::shouldArm(kDsn, kDsn, false));
+}
+
+static void test_arms_when_both_hold() {
+ TEST("shouldArm: a compiled DSN plus consent arms");
+ EXPECT(crash::shouldArm(kDsn, nullptr, true));
+}
+
+static void test_env_override_is_the_dev_hatch() {
+ TEST("shouldArm: $SENTRY_DSN substitutes for a compiled DSN, with consent");
+ EXPECT(crash::shouldArm("", kDsn, true));
+ EXPECT(crash::shouldArm(nullptr, kDsn, true));
+}
+
+static void test_test_build_carries_no_dsn() {
+ TEST("the test binary itself has no DSN compiled in");
+ // If this ever fails, a build has been configured in a way that would let
+ // `ctest` transmit, which no test run should ever be able to do.
+ EXPECT(std::string(crash::compiledDsn()).empty());
+ EXPECT(!crash::sdkAvailable());
+ EXPECT_EQ(std::string(crash::environment()), std::string("development"));
+}
+
+static void test_default_is_off() {
+ TEST("Config: crash reporting defaults off");
+ // An install upgrading into this feature has an operator who was never
+ // asked, so the default has to answer "no" on their behalf.
+ Config cfg;
+ EXPECT(!cfg.crashReporting);
+}
+
+static void test_absent_key_reads_as_off() {
+ TEST("Config: a config predating the key reads as off, not as opt-in");
+ Config cfg;
+ parseConfigInto(R"({"udpPort":9876})", cfg);
+ EXPECT(!cfg.crashReporting);
+ EXPECT_EQ(cfg.udpPort, 9876);
+}
+
+static void test_round_trip() {
+ TEST("Config: crash reporting survives a serialize/parse round-trip");
+ Config in;
+ in.crashReporting = true;
+ Config out;
+ parseConfigInto(serializeConfig(in), out);
+ EXPECT(out.crashReporting);
+
+ // An explicit false must survive against a struct whose field is already
+ // true, or a user's opt-out would silently revert on the next load.
+ Config off;
+ off.crashReporting = false;
+ Config wasOn;
+ wasOn.crashReporting = true;
+ parseConfigInto(serializeConfig(off), wasOn);
+ EXPECT(!wasOn.crashReporting);
+}
+
+static void test_inactive_without_sdk() {
+ TEST("crash::active() stays false when the SDK is absent");
+ EXPECT(!crash::active());
+ // The test binary compiles the facade without SATELLITE_HAS_SENTRY and
+ // with an empty DSN, so these must be no-ops rather than crashes.
+ crash::init(true, std::string());
+ EXPECT(!crash::active());
+ crash::setEnabled(true);
+ EXPECT(!crash::active());
+ crash::setEnabled(false);
+ EXPECT(!crash::active());
+ crash::shutdown();
+ EXPECT(!crash::active());
+}
+
+int main() {
+ test_no_dsn_never_arms();
+ test_opt_out_never_arms();
+ test_arms_when_both_hold();
+ test_env_override_is_the_dev_hatch();
+ test_test_build_carries_no_dsn();
+ test_default_is_off();
+ test_absent_key_reads_as_off();
+ test_round_trip();
+ test_inactive_without_sdk();
+
+ std::cout << "crash_reporting: " << g_pass << " passed, " << g_fail << " failed\n";
+ return g_fail == 0 ? 0 : 1;
+}
diff --git a/tests/test_status_json.cpp b/tests/test_status_json.cpp
index 8e495d7..88fdc8d 100644
--- a/tests/test_status_json.cpp
+++ b/tests/test_status_json.cpp
@@ -60,6 +60,7 @@ static void test_status_exact_shape() {
R"("webPort":9871,"autoStart":true,"discoveryBroadcastEnabled":false,)"
R"("controllerAudio":false,"controllerAudioMic":true,"controllerAudioSpeaker":true,)"
R"("controllerAudioKeepDefaultDevice":true,)"
+ R"("crashReporting":false,"crashReportingActive":false,)"
R"("mdnsResponderActive":true,"backendAvailable":true,)"
R"("backend":{"kind":"vigem","available":true}})"));
}
diff --git a/vcpkg.json b/vcpkg.json
index 19cf36e..5cb3b3a 100644
--- a/vcpkg.json
+++ b/vcpkg.json
@@ -7,6 +7,7 @@
"dependencies": [
"libsodium",
"openssl",
- "opus"
+ "opus",
+ "sentry-native"
]
}
diff --git a/web/index.html b/web/index.html
index 4b67459..d1b7ba5 100644
--- a/web/index.html
+++ b/web/index.html
@@ -209,6 +209,18 @@
Satellite puts your previous device back afterwards. Turn it off if you
want the controller to become the default the way Windows intended.
+
Diagnostics
+
+ Send crash reports
+
+
+
Off by default. When on, Satellite sends a report to its
+ developers if it crashes: the stack trace, the version and the operating
+ system. It never sends your gameplay, your controller input, your paired
+ devices or the contents of your network. Turning it off stops the very
+ next report, not just future ones.
+
+
diff --git a/web/lang/bs.json b/web/lang/bs.json
index 6999062..e31078d 100644
--- a/web/lang/bs.json
+++ b/web/lang/bs.json
@@ -221,6 +221,13 @@
"settings.discovery.mdns-label": "mDNS / Bonjour responder",
"settings.discovery.broadcast-label": "Naslijeđeni UDP broadcast signal",
"settings.discovery.hint": "mDNS / Bonjour je savremeni način otkrivanja i radi na podmrežama koje odbacuju broadcast. Naslijeđeni UDP signal ostaje uključen kao rezerva za pošiljaoce starije od mDNS respondera. Isključite ga samo na mreži gdje svaki pošiljalac podržava mDNS.",
+ "settings.crash.title": "Dijagnostika",
+ "settings.crash.label": "Šalji izvještaje o padovima",
+ "settings.crash.hint": "Isključeno po zadanom. Kada je uključeno, Satellite šalje izvještaj svojim programerima ako se sruši: stack trace, verziju i operativni sistem. Nikada ne šalje tvoju igru, unos s kontrolera, uparene uređaje niti sadržaj tvoje mreže. Isključivanje zaustavlja već sljedeći izvještaj, ne samo buduće.",
+ "settings.crash.status.off": "Izvještaji o padovima se ne šalju.",
+ "settings.crash.status.on": "Izvještaji o padovima se šalju.",
+ "settings.crash.status.unavailable": "Ovaj build ne može slati izvještaje o padovima, pa se ništa ne šalje. Zvanična izdanja mogu.",
+
"settings.audio.title": "Zvuk kontrolera",
"settings.audio.label": "Mikrofon i zvučnik kontrolera",
"settings.audio.hint": "DualSense ili DualShock 4 v2 napravljen ovdje dobija i vlastiti mikrofon i zvučnik kontrolera, kao stvarne Windows audio uređaje. Windows odlučuje šta se kroz njih pušta, isto kao za bilo koje slušalice: izaberite kontroler u Postavke > Sistem > Zvuk > Mikser jačine zvuka za aplikacije koje želite na njemu. Zvuk kontrolera zahtijeva priloženi potpisani USB transport u kernelu, koji HIDMaestro instalira prvi put kada se takav kontroler napravi. Isključite ovu opciju da zadržite kontroler samo za unos i preskočite tu instalaciju. Promjene važe od sljedećeg povezivanja kontrolera.",
diff --git a/web/lang/de.json b/web/lang/de.json
index dccaa7c..8b06091 100644
--- a/web/lang/de.json
+++ b/web/lang/de.json
@@ -241,6 +241,13 @@
"settings.discovery.mdns-label": "mDNS-/Bonjour-Responder",
"settings.discovery.broadcast-label": "Klassischer UDP-Broadcast-Beacon",
"settings.discovery.hint": "mDNS / Bonjour ist der moderne Erkennungsweg und funktioniert auch in Subnetzen, die Broadcasts verwerfen. Der klassische UDP-Beacon bleibt als Ausweichlösung für Sender aktiv, die älter als der mDNS-Responder sind. Deaktivieren Sie ihn nur in einem Netzwerk, in dem jeder Sender mDNS unterstützt.",
+ "settings.crash.title": "Diagnose",
+ "settings.crash.label": "Absturzberichte senden",
+ "settings.crash.hint": "Standardmäßig aus. Wenn aktiviert, sendet Satellite bei einem Absturz einen Bericht an die Entwickler: den Stacktrace, die Version und das Betriebssystem. Niemals gesendet werden dein Spielgeschehen, deine Controller-Eingaben, deine gekoppelten Geräte oder Inhalte aus deinem Netzwerk. Beim Ausschalten entfällt schon der nächste Bericht, nicht erst die späteren.",
+ "settings.crash.status.off": "Es werden keine Absturzberichte gesendet.",
+ "settings.crash.status.on": "Absturzberichte werden gesendet.",
+ "settings.crash.status.unavailable": "Dieser Build kann keine Absturzberichte senden, es wird also nichts gesendet. Offizielle Releases können es.",
+
"settings.audio.title": "Controller-Audio",
"settings.audio.label": "Mikrofon und Lautsprecher des Controllers",
"settings.audio.hint": "Ein hier erstellter DualSense oder DualShock 4 v2 erhält auch das eigene Mikrofon und den eigenen Lautsprecher des Controllers, als echte Windows-Audiogeräte. Windows entscheidet, was darüber wiedergegeben wird, genau wie bei jedem Headset: Wählen Sie den Controller unter Einstellungen > System > Sound > Lautstärkemixer für die gewünschten Apps aus. Controller-Audio benötigt einen mitgelieferten signierten USB-Kerneltransport, den HIDMaestro beim ersten Erstellen eines solchen Controllers installiert. Deaktivieren Sie diese Option, um den reinen Eingabe-Controller zu behalten und diese Installation zu überspringen. Änderungen gelten ab der nächsten Controller-Verbindung.",
diff --git a/web/lang/en.json b/web/lang/en.json
index a50bdb0..84f1a99 100644
--- a/web/lang/en.json
+++ b/web/lang/en.json
@@ -241,6 +241,13 @@
"settings.discovery.mdns-label": "mDNS / Bonjour responder",
"settings.discovery.broadcast-label": "Legacy UDP broadcast beacon",
"settings.discovery.hint": "mDNS / Bonjour is the modern discovery path and works on subnets that drop broadcast. The legacy UDP beacon stays on as a fallback for senders that predate the mDNS responder. Turn it off only on a network where every sender supports mDNS.",
+ "settings.crash.title": "Diagnostics",
+ "settings.crash.label": "Send crash reports",
+ "settings.crash.hint": "Off by default. When on, Satellite sends a report to its developers if it crashes: the stack trace, the version and the operating system. It never sends your gameplay, your controller input, your paired devices or the contents of your network. Turning it off stops the very next report, not just future ones.",
+ "settings.crash.status.off": "Crash reports are not being sent.",
+ "settings.crash.status.on": "Crash reports are being sent.",
+ "settings.crash.status.unavailable": "This build cannot send crash reports, so nothing is being sent. Official releases can.",
+
"settings.audio.title": "Controller audio",
"settings.audio.label": "Controller microphone and speaker",
"settings.audio.hint": "A DualSense or DualShock 4 v2 created here also gets the controller's own microphone and speaker, as real Windows audio devices. Windows decides what plays through them, the same as for any headset: pick the controller under Settings > System > Sound > Volume mixer for the apps you want on it. Controller audio needs a bundled signed kernel USB transport, which HIDMaestro installs the first time such a controller is created. Turn this off to keep the input-only controller and skip that install. Changes apply the next time a controller is connected.",
diff --git a/web/lang/es.json b/web/lang/es.json
index 12747bd..d077950 100644
--- a/web/lang/es.json
+++ b/web/lang/es.json
@@ -221,6 +221,13 @@
"settings.discovery.mdns-label": "Respondedor mDNS / Bonjour",
"settings.discovery.broadcast-label": "Baliza de difusión UDP heredada",
"settings.discovery.hint": "mDNS / Bonjour es la vía de descubrimiento moderna y funciona en subredes que descartan la difusión. La baliza UDP heredada se mantiene activa como alternativa para los emisores anteriores al respondedor mDNS. Desactívala solo en una red donde todos los emisores admitan mDNS.",
+ "settings.crash.title": "Diagnóstico",
+ "settings.crash.label": "Enviar informes de fallos",
+ "settings.crash.hint": "Desactivado de forma predeterminada. Si se activa, Satellite envía un informe a sus desarrolladores cuando falla: la traza de pila, la versión y el sistema operativo. Nunca envía tu partida, tus mandos, tus dispositivos vinculados ni el contenido de tu red. Desactivarlo detiene ya el siguiente informe, no solo los posteriores.",
+ "settings.crash.status.off": "No se están enviando informes de fallos.",
+ "settings.crash.status.on": "Se están enviando informes de fallos.",
+ "settings.crash.status.unavailable": "Esta compilación no puede enviar informes de fallos, así que no se envía nada. Las versiones oficiales sí pueden.",
+
"settings.audio.title": "Audio del mando",
"settings.audio.label": "Micrófono y altavoz del mando",
"settings.audio.hint": "Un DualSense o un DualShock 4 v2 creado aquí también obtiene el micrófono y el altavoz propios del mando, como dispositivos de audio reales de Windows. Windows decide qué suena por ellos, igual que con cualquier auricular: elige el mando en Configuración > Sistema > Sonido > Mezclador de volumen para las aplicaciones que quieras. El audio del mando necesita un transporte USB de núcleo firmado e incluido, que HIDMaestro instala la primera vez que se crea un mando de este tipo. Desactiva esta opción para conservar el mando solo de entrada y omitir esa instalación. Los cambios se aplican la próxima vez que se conecte un mando.",
diff --git a/web/lang/fr.json b/web/lang/fr.json
index 9544106..2ed31ce 100644
--- a/web/lang/fr.json
+++ b/web/lang/fr.json
@@ -221,6 +221,13 @@
"settings.discovery.mdns-label": "Répondeur mDNS / Bonjour",
"settings.discovery.broadcast-label": "Balise UDP de diffusion (héritée)",
"settings.discovery.hint": "mDNS / Bonjour est la méthode de découverte moderne et fonctionne sur les sous-réseaux qui bloquent la diffusion. La balise UDP héritée reste active comme solution de repli pour les émetteurs antérieurs au répondeur mDNS. Ne la désactivez que sur un réseau où tous les émetteurs prennent en charge mDNS.",
+ "settings.crash.title": "Diagnostics",
+ "settings.crash.label": "Envoyer les rapports de plantage",
+ "settings.crash.hint": "Désactivé par défaut. Une fois activé, Satellite envoie un rapport à ses développeurs en cas de plantage : la pile d'appels, la version et le système d'exploitation. Il n'envoie jamais votre jeu, vos commandes, vos appareils appairés ni le contenu de votre réseau. Le désactiver arrête dès le prochain rapport, pas seulement les suivants.",
+ "settings.crash.status.off": "Aucun rapport de plantage n'est envoyé.",
+ "settings.crash.status.on": "Les rapports de plantage sont envoyés.",
+ "settings.crash.status.unavailable": "Cette version ne peut pas envoyer de rapports de plantage : rien n'est envoyé. Les versions officielles le peuvent.",
+
"settings.audio.title": "Audio de la manette",
"settings.audio.label": "Microphone et haut-parleur de la manette",
"settings.audio.hint": "Une DualSense ou une DualShock 4 v2 créée ici obtient aussi le microphone et le haut-parleur propres à la manette, comme de vrais périphériques audio Windows. C'est Windows qui décide de ce qui y est joué, comme pour n'importe quel casque : choisissez la manette dans Paramètres > Système > Son > Mélangeur de volume pour les applications voulues. L'audio de la manette nécessite un transport USB noyau signé et fourni, que HIDMaestro installe la première fois qu'une telle manette est créée. Désactivez cette option pour conserver la manette en entrée seule et éviter cette installation. Les changements s'appliquent à la prochaine connexion d'une manette.",
diff --git a/web/lang/pt-BR.json b/web/lang/pt-BR.json
index b39cb07..3c9897e 100644
--- a/web/lang/pt-BR.json
+++ b/web/lang/pt-BR.json
@@ -221,6 +221,13 @@
"settings.discovery.mdns-label": "Respondedor mDNS / Bonjour",
"settings.discovery.broadcast-label": "Beacon de broadcast UDP legado",
"settings.discovery.hint": "O mDNS / Bonjour é o método moderno de descoberta e funciona em sub-redes que descartam broadcast. O beacon UDP legado permanece ativo como alternativa para remetentes anteriores ao respondedor mDNS. Desative-o apenas em uma rede onde todos os remetentes sejam compatíveis com mDNS.",
+ "settings.crash.title": "Diagnóstico",
+ "settings.crash.label": "Enviar relatórios de falhas",
+ "settings.crash.hint": "Desativado por padrão. Quando ativado, o Satellite envia um relatório aos desenvolvedores se travar: o rastreamento de pilha, a versão e o sistema operacional. Nunca envia seu jogo, os comandos do controle, seus dispositivos pareados nem o conteúdo da sua rede. Desativar já interrompe o próximo relatório, não apenas os seguintes.",
+ "settings.crash.status.off": "Nenhum relatório de falhas está sendo enviado.",
+ "settings.crash.status.on": "Relatórios de falhas estão sendo enviados.",
+ "settings.crash.status.unavailable": "Esta compilação não pode enviar relatórios de falhas, portanto nada é enviado. As versões oficiais podem.",
+
"settings.audio.title": "Áudio do controle",
"settings.audio.label": "Microfone e alto-falante do controle",
"settings.audio.hint": "Um DualSense ou DualShock 4 v2 criado aqui também ganha o microfone e o alto-falante do próprio controle, como dispositivos de áudio reais do Windows. O Windows decide o que toca por eles, igual a qualquer fone: escolha o controle em Configurações > Sistema > Som > Mixer de volume para os aplicativos que quiser. O áudio do controle precisa de um transporte USB de kernel assinado e incluído, que o HIDMaestro instala na primeira vez que um controle desse tipo é criado. Desative esta opção para manter o controle apenas de entrada e pular essa instalação. As mudanças valem na próxima vez que um controle for conectado.",
diff --git a/web/settings.js b/web/settings.js
index fd047ff..afa3c12 100644
--- a/web/settings.js
+++ b/web/settings.js
@@ -10,6 +10,14 @@ const SETTINGS_AUDIO_KEYS = [
'controllerAudioMic', 'controllerAudioSpeaker', 'controllerAudioKeepDefaultDevice',
];
+// Same DOM-id/config-key/POST-key convention as the audio switches, but NOT
+// gated by the controller-audio master switch, and with the opposite default:
+// an absent key reads as OFF. These are consent switches, so a server that
+// predates them, or one that never answered, must not be shown as opted in.
+const SETTINGS_OPTIN_KEYS = ['crashReporting'];
+
+function settingsOptInEl(key) { return document.getElementById('settings-' + key); }
+
function settingsAudioEl(key) { return document.getElementById('settings-' + key); }
function settingsCheckDirty() {
@@ -21,12 +29,17 @@ function settingsCheckDirty() {
const el = settingsAudioEl(k);
return el && el.checked !== settingsSavedConfig[k];
});
+ const optInDirty = SETTINGS_OPTIN_KEYS.some((k) => {
+ const el = settingsOptInEl(k);
+ return el && el.checked !== settingsSavedConfig[k];
+ });
const dirty =
curPort !== settingsSavedConfig.udpPort ||
curAuto !== settingsSavedConfig.autoStart ||
curBroadcast !== settingsSavedConfig.discoveryBroadcast ||
curAudio !== settingsSavedConfig.controllerAudio ||
- audioDirty;
+ audioDirty ||
+ optInDirty;
document.getElementById('settings-btnSave').disabled = !dirty;
document.getElementById('settings-btnUndo').disabled = !dirty;
// Editing clears stale validation/save messages so a prior error doesn't
@@ -48,6 +61,10 @@ function settingsUndo() {
const el = settingsAudioEl(k);
if (el) el.checked = settingsSavedConfig[k];
});
+ SETTINGS_OPTIN_KEYS.forEach((k) => {
+ const el = settingsOptInEl(k);
+ if (el) el.checked = settingsSavedConfig[k];
+ });
settingsSetPortError('');
settingsSetSaveStatus('', false);
settingsCheckDirty();
@@ -90,6 +107,10 @@ async function settingsSave() {
const el = settingsAudioEl(k);
if (el) payload[k] = el.checked;
});
+ SETTINGS_OPTIN_KEYS.forEach((k) => {
+ const el = settingsOptInEl(k);
+ if (el) payload[k] = el.checked;
+ });
const res = await apiPost('/api/config', payload);
// Only commit saved-state when the server confirmed the write, else a
@@ -115,6 +136,10 @@ async function settingsSave() {
const el = settingsAudioEl(k);
if (el) settingsSavedConfig[k] = el.checked;
});
+ SETTINGS_OPTIN_KEYS.forEach((k) => {
+ const el = settingsOptInEl(k);
+ if (el) settingsSavedConfig[k] = el.checked;
+ });
document.getElementById('settings-udpPort').value = effectivePort;
settingsCheckDirty();
@@ -152,6 +177,9 @@ async function initSettings() {
// Same rule for a server that predates controller audio: absent is on.
settingsSavedConfig.controllerAudio = d.controllerAudio !== false;
SETTINGS_AUDIO_KEYS.forEach((k) => { settingsSavedConfig[k] = d[k] !== false; });
+ // `=== true`, not `!== false`: consent defaults to off when the server
+ // says nothing, which is the reverse of every switch above.
+ SETTINGS_OPTIN_KEYS.forEach((k) => { settingsSavedConfig[k] = d[k] === true; });
document.getElementById('settings-udpPort').value = d.udpPort;
document.getElementById('settings-autoStart').checked = d.autoStart;
document.getElementById('settings-discoveryBroadcast').checked =
@@ -162,6 +190,12 @@ async function initSettings() {
const el = settingsAudioEl(k);
if (el) el.checked = settingsSavedConfig[k];
});
+ SETTINGS_OPTIN_KEYS.forEach((k) => {
+ const el = settingsOptInEl(k);
+ if (el) el.checked = settingsSavedConfig[k];
+ });
+ settingsRenderCrashReportingHint(d.crashReportingActive === true,
+ settingsSavedConfig.crashReporting === true);
settingsRenderMdnsStatus(d.mdnsResponderActive === true);
} catch (e) { /* ignore */ }
@@ -179,6 +213,10 @@ async function initSettings() {
const el = settingsAudioEl(k);
if (el) el.addEventListener('change', settingsCheckDirty);
});
+ SETTINGS_OPTIN_KEYS.forEach((k) => {
+ const el = settingsOptInEl(k);
+ if (el) el.addEventListener('change', settingsCheckDirty);
+ });
// "Start with Windows" reads odd on Mac/Linux, so adapt the label per OS.
try {
@@ -270,3 +308,14 @@ async function settingsInterfaceChange() {
if (note) note.textContent = res.ok ? t('netinfo.restart-note') : t('settings.save.unreachable');
initNetworkSettings();
}
+
+// A build with no DSN compiled in cannot transmit no matter what the switch
+// says, and the UI must not imply otherwise. This is the honest-label rule:
+// say "on, and sending" only when the server confirms it actually armed.
+function settingsRenderCrashReportingHint(active, wanted) {
+ const el = document.getElementById('settings-crashReporting-status');
+ if (!el) return;
+ if (!wanted) { el.textContent = t('settings.crash.status.off'); return; }
+ el.textContent = active ? t('settings.crash.status.on')
+ : t('settings.crash.status.unavailable');
+}