From 119fe00d1112935316b69a254c62524875dec02f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:14:48 -0400 Subject: [PATCH] fix(license)!: remove expiration-based license validation Validate annual subscriptions using recent Polar entitlement checks, normalize pasted license keys, and keep activation available despite stale broker status. BREAKING CHANGE: Remove expires_at from the public license status and broker protocol. --- .github/workflows/ci.yml | 2 +- docs/usage.md | 25 ++- docs/windows-driver.md | 60 +++--- src/include/libvirtualhid/license.hpp | 1 - .../windows/broker/libvirtualhid_broker.cpp | 188 +++--------------- .../shared/lvh_windows_broker_config.hpp | 6 +- .../shared/lvh_windows_broker_protocol.h | 4 +- src/platform/windows/windows_license.cpp | 1 - .../windows_broker_service_test_hooks.hpp | 11 +- .../windows_broker_service_test_hooks.cpp | 37 ++-- tests/unit/test_license.cpp | 12 +- tests/unit/test_virtualhid_control_model.cpp | 6 + tests/unit/test_windows_broker_service.cpp | 10 +- tools/virtualhid_control.cpp | 15 +- tools/virtualhid_control_model.cpp | 11 + tools/virtualhid_control_model.hpp | 9 + 16 files changed, 166 insertions(+), 232 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6eb38b8..20c90bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: with: allowUpdates: false body: ${{ needs.setup_release.outputs.release_body }} - draft: true + draft: false generateReleaseNotes: ${{ needs.setup_release.outputs.release_generate_release_notes }} name: ${{ needs.setup_release.outputs.release_tag }} prerelease: true diff --git a/docs/usage.md b/docs/usage.md index c8088c8..9de3c37 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -112,17 +112,24 @@ an online request per controller. The broker validates in the background at startup and once per day. If Polar cannot be reached, it retries every 60 seconds. Existing gamepads are retained for one hour, but a new gamepad can be created only when no licensed gamepad is active. When the outage reaches one -hour, the broker removes excess licensed gamepads and retains at most one. +hour, the broker removes excess licensed gamepads and retains at most one. A +yearly subscription must validate successfully within 25 hours of its previous +validation, so the remaining gamepad is removed when that deadline passes. A +lifetime license can retain the one-gamepad fallback until validation succeeds. If the broker service restarts, it removes gamepads left by the previous broker instance before accepting new creation requests. Failed removals are retried. -Yearly licenses stop at their exact `expires_at`; this outage behavior never -extends expiration. Lifetime licenses have no expiration. Polar server time, -Windows uptime, and a per-boot marker track time without relying on the -user-adjustable Windows date. After Windows restarts, a yearly license must -reconnect to Polar before gamepad creation; lifetime licenses can use the -one-gamepad outage fallback. A confirmed missing, revoked, disabled, expired, or -mismatched entitlement invalidates the license and removes all licensed gamepads. Purchase and -account-management buttons use the compiled URLs in +Both supported plans rely on Polar's entitlement status rather than a locally +enforced calendar expiration. A granted yearly license follows its subscription +benefit, which Polar revokes when the entitlement ends. Polar's public license +response does not include the subscription renewal date; the linked Polar account +portal remains authoritative instead of the broker estimating a date. Polar +server time, Windows uptime, and a per-boot marker track subscription validation +age without relying on the user-adjustable Windows date. After Windows restarts, +a yearly subscription must reconnect to Polar before gamepad creation; a lifetime +license can use the one-gamepad outage fallback. A confirmed missing, revoked, +disabled, or mismatched entitlement invalidates the license and removes all +licensed gamepads. +Purchase and account-management buttons use the compiled URLs in `src/platform/windows/shared/lvh_windows_broker_config.hpp`. Enable `Lock buttons` to click-to-toggle behavior for held inputs. The resizable window supports a compact width. Its device and control panels diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 738938f..4fdf57d 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -311,21 +311,30 @@ provider failure, the broker retries every 60 seconds. Controllers that already exist are retained for one hour unless the broker service restarts, but no additional controller can be created while at least one licensed controller remains active. When the outage reaches one hour, the broker removes excess -licensed controllers and retains at most one until online validation succeeds. -Failed driver destruction requests remain tracked and are retried instead of -being treated as successful revocations. +licensed controllers and retains at most one. A yearly subscription authorization +is current for at most the daily validation interval plus that one-hour outage +allowance; after 25 hours without successful validation, the remaining licensed +controller is also removed. A lifetime license can retain the one-controller +fallback until online validation succeeds. Failed driver destruction requests +remain tracked and are retried instead of being treated as successful revocations. Polar's HTTPS `Date` response header supplies trusted time when a new -authorization is issued. A yearly license ends exactly at its reported -`expires_at`; the one-hour outage retention does not extend that expiration. A -lifetime license has no calendar expiration. The broker advances Polar's trusted -timestamp using Windows uptime and stores a random marker in a volatile registry -key for the current boot session. This works across broker service restarts and -includes sleep or hibernation, but never consults the user-adjustable Windows -date. After Windows restarts, the marker changes, so a yearly license must -reconnect to Polar before gamepad creation; lifetime licenses can use the -one-gamepad outage fallback. Explicit validation requests always contact the -provider. The sole exception to normal licensing is +authorization is issued. Both supported plans rely on Polar's entitlement status +rather than a locally enforced calendar expiration. Subscription keys remain +granted while their subscription is billable, and Polar revokes the benefit when +the subscription entitlement ends. Polar's public license validation response +does not include the subscription renewal date, so the broker does not fabricate +one; customers can see the authoritative date in the linked Polar account portal. +The one-hour outage retention does not extend the yearly subscription's 25-hour +validation deadline. + +The broker advances Polar's trusted timestamp using Windows uptime and stores a +random marker in a volatile registry key for the current boot session. This works +across broker service restarts and includes sleep or hibernation, but never +consults the user-adjustable Windows date. After Windows restarts, the marker +changes, so a yearly subscription must reconnect to Polar before gamepad creation; +a lifetime license can use the one-gamepad outage fallback. Explicit validation +requests always contact the provider. The sole exception to normal licensing is for CI runners where the broker service itself has the `GITHUB_ACTIONS` environment marker. That environment receives one machine-scoped five-minute evaluation window beginning with its first unlicensed creation attempt. The @@ -336,15 +345,17 @@ separately running service. Polar's `limit_activations` value is the machine limit and is configured as `5` on both license-key benefits. The broker gives yearly and lifetime licenses the -same full local access when the provider reports the key status as `granted`. Polar -revokes a subscription benefit when its entitlement ends. Licensed access has -no local active-device cap after successful validation. A definitive missing -activation, revoked or disabled key, activation mismatch, disallowed benefit, -explicit deactivation, or exact yearly expiration prevents new gamepads and -causes the broker to destroy existing licensed gamepads. A timeout or other +same full local access when the provider reports the key status as `granted`. +Polar revokes a subscription benefit when its entitlement ends. Licensed access +has no local active-device cap after successful validation. A definitive missing +activation, revoked or disabled key, activation mismatch, disallowed benefit, or +explicit deactivation prevents new gamepads and causes the broker to destroy +existing licensed gamepads. A timeout or other transient provider failure starts the one-hour retention period and one-gamepad -creation limit instead of immediately revoking existing controllers. WinHTTP resolve, connect, send, and receive -operations have explicit timeouts of 5, 5, 5, and 10 seconds respectively. +creation limit instead of immediately revoking existing controllers. A yearly +subscription that cannot validate for 25 hours is also denied until it reconnects. +WinHTTP resolve, connect, send, and receive operations have explicit timeouts of +5, 5, 5, and 10 seconds respectively. ## Profile Compatibility @@ -393,9 +404,10 @@ label because VHF does not provide a product/manufacturer string callback. packages require a Microsoft dashboard signing path that is not part of the current Azure Trusted Signing workflow. - A temporary Polar outage limits a previously activated machine to one active - licensed gamepad until validation succeeds. Yearly licenses receive no - post-expiration grace. Definitive invalidation or exact expiration prevents - new gamepads and removes active licensed gamepads. + licensed gamepad. Yearly subscriptions must reconnect within 25 hours of their + last successful validation; lifetime licenses can retain one gamepad until + validation succeeds. Definitive invalidation prevents new gamepads and removes + active licensed gamepads. ## Signing diff --git a/src/include/libvirtualhid/license.hpp b/src/include/libvirtualhid/license.hpp index e87b257..c95d9f9 100644 --- a/src/include/libvirtualhid/license.hpp +++ b/src/include/libvirtualhid/license.hpp @@ -37,7 +37,6 @@ namespace lvh { std::uint32_t activation_usage = 0; ///< Machine activations currently used by the license. std::string plan_name; ///< Human-readable plan name, when available. std::string customer_email; ///< Customer email associated with the license, when available. - std::string expires_at; ///< Provider-formatted expiration timestamp, when applicable. std::string message; ///< Human-readable license service status. std::string purchase_url; ///< Hosted page where a license can be purchased. std::string manage_account_url; ///< Hosted page where the customer can manage activations. diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp index c9a487f..10c4690 100644 --- a/src/platform/windows/broker/libvirtualhid_broker.cpp +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -83,6 +83,8 @@ namespace lvh::detail::windows_broker_service { constexpr auto license_validation_interval = std::chrono::days {1}; constexpr auto license_validation_retry_interval = std::chrono::seconds {60}; constexpr auto license_outage_device_retention = std::chrono::hours {1}; + constexpr auto subscription_validation_max_age = + license_validation_interval + license_outage_device_retention; constexpr std::size_t unvalidated_active_gamepad_limit = 1U; constexpr auto boot_session_registry_path = L"SYSTEM\\CurrentControlSet\\Services\\libvirtualhid_broker\\Runtime"; @@ -186,112 +188,6 @@ namespace lvh::detail::windows_broker_service { return encode_boot_marker(*generated); } - bool valid_rfc3339_date_time_parts( - const std::optional &year, - const std::optional &month, - const std::optional &day, - const std::optional &hour, - const std::optional &minute, - const std::optional &second - ) { - return year.has_value() && month.has_value() && day.has_value() && - hour.has_value() && minute.has_value() && second.has_value() && - *hour <= 23U && *minute <= 59U && *second <= 60U; - } - - std::optional parse_rfc3339_part( - std::string_view value, - std::size_t offset, - std::size_t size - ) { - if (offset > value.size() || size > value.size() - offset) { - return std::nullopt; - } - - unsigned parsed = 0; - const auto *begin = value.data() + offset; - const auto *end = begin + size; - const auto result = std::from_chars(begin, end, parsed); - return result.ec == std::errc {} && result.ptr == end ? - std::optional {parsed} : - std::nullopt; - } - - std::optional parse_rfc3339_utc_offset( - std::string_view value - ) { - std::size_t timezone_offset = 19U; - if (timezone_offset < value.size() && value[timezone_offset] == '.') { - ++timezone_offset; - const auto fraction_start = timezone_offset; - while (timezone_offset < value.size() && value[timezone_offset] >= '0' && value[timezone_offset] <= '9') { - ++timezone_offset; - } - if (timezone_offset == fraction_start) { - return std::nullopt; - } - } - - if (timezone_offset >= value.size()) { - return std::nullopt; - } - if (value[timezone_offset] == 'Z' || value[timezone_offset] == 'z') { - return timezone_offset + 1U == value.size() ? - std::optional {std::chrono::minutes::zero()} : - std::nullopt; - } - if (timezone_offset + 6U != value.size() || (value[timezone_offset] != '+' && value[timezone_offset] != '-') || value[timezone_offset + 3U] != ':') { - return std::nullopt; - } - - const auto offset_hours = parse_rfc3339_part(value, timezone_offset + 1U, 2U); - const auto offset_minutes = parse_rfc3339_part(value, timezone_offset + 4U, 2U); - if (!offset_hours.has_value() || !offset_minutes.has_value() || *offset_hours > 23U || *offset_minutes > 59U) { - return std::nullopt; - } - - auto utc_offset = std::chrono::hours {*offset_hours} + std::chrono::minutes {*offset_minutes}; - return value[timezone_offset] == '-' ? -utc_offset : utc_offset; - } - - std::optional parse_rfc3339_timestamp( - std::string_view value - ) { - if (value.size() < 20U || value[4] != '-' || value[7] != '-' || (value[10] != 'T' && value[10] != 't') || value[13] != ':' || value[16] != ':') { - return std::nullopt; - } - - const auto year_value = parse_rfc3339_part(value, 0U, 4U); - const auto month_value = parse_rfc3339_part(value, 5U, 2U); - const auto day_value = parse_rfc3339_part(value, 8U, 2U); - const auto hour_value = parse_rfc3339_part(value, 11U, 2U); - const auto minute_value = parse_rfc3339_part(value, 14U, 2U); - const auto second_value = parse_rfc3339_part(value, 17U, 2U); - if (!valid_rfc3339_date_time_parts(year_value, month_value, day_value, hour_value, minute_value, second_value)) { - return std::nullopt; - } - - const auto utc_offset = parse_rfc3339_utc_offset(value); - if (!utc_offset.has_value()) { - return std::nullopt; - } - - const auto date = std::chrono::year_month_day { - std::chrono::year {static_cast(*year_value)}, - std::chrono::month {*month_value}, - std::chrono::day {*day_value}, - }; - if (!date.ok()) { - return std::nullopt; - } - - return std::chrono::sys_days {date} + - std::chrono::hours {*hour_value} + - std::chrono::minutes {*minute_value} + - std::chrono::seconds {std::min(*second_value, 59U)} - - *utc_offset; - } - std::optional license_server_time( const SYSTEMTIME &system_time ) { @@ -337,17 +233,18 @@ namespace lvh::detail::windows_broker_service { std::optional {calendar_anchor + elapsed_unsigned}; } - bool license_expiration_is_current( - std::string_view expires_at, - bool expiration_required, + bool license_validation_is_current( + std::uint64_t validated_at, std::uint64_t effective_timestamp ) { - if (!expiration_required) { - return true; - } - const auto expiration = parse_rfc3339_timestamp(expires_at); - return expiration && - effective_timestamp < license_calendar_timestamp(*expiration); + const auto maximum_age = static_cast( + std::chrono::duration_cast( + subscription_validation_max_age + ) + .count() + ); + return validated_at != 0U && effective_timestamp >= validated_at && + effective_timestamp - validated_at < maximum_age; } bool unvalidated_gamepad_creation_allowed( @@ -739,7 +636,6 @@ namespace lvh::detail::windows_broker_service { std::string organization_id; std::string benefit_id; std::string customer_email; - std::string expires_at; std::uint32_t activation_limit = 0; // Audit timestamp supplied by Polar, not the local machine clock. std::uint64_t validated_at = 0; @@ -759,7 +655,6 @@ namespace lvh::detail::windows_broker_service { serialized << "organization_id=" << state.organization_id << "\n"; serialized << "benefit_id=" << state.benefit_id << "\n"; serialized << "customer_email=" << state.customer_email << "\n"; - serialized << "expires_at=" << state.expires_at << "\n"; serialized << "activation_limit=" << state.activation_limit << "\n"; serialized << "validated_at=" << state.validated_at << "\n"; serialized << "boot_marker=" << state.boot_marker << "\n"; @@ -898,8 +793,6 @@ namespace lvh::detail::windows_broker_service { state.benefit_id = value; } else if (key == "customer_email") { state.customer_email = value; - } else if (key == "expires_at") { - state.expires_at = value; } else if (key == "activation_limit") { state.activation_limit = static_cast(parse_uint64(value).value_or(0)); } else if (key == "validated_at") { @@ -1147,7 +1040,6 @@ namespace lvh::detail::windows_broker_service { state.license_status = json_string_or_empty(body, "status"); state.organization_id = json_string_or_empty(body, "organization_id"); state.benefit_id = json_string_or_empty(body, "benefit_id"); - state.expires_at = json_string_or_empty(body, "expires_at"); state.activation_limit = json_uint32_or_zero(body, "limit_activations"); if (const auto customer = body.find("customer"); customer != body.end() && customer->is_object()) { @@ -1621,7 +1513,7 @@ namespace lvh::detail::windows_broker_service { now ); const auto license_now = LicenseValidationClock::now(); - if (license_expiration_has_elapsed_locked()) { + if (subscription_validation_has_elapsed_locked()) { revoke_licensed_devices_ = true; } const auto revoke_licensed_devices = revoke_licensed_devices_; @@ -2007,24 +1899,11 @@ namespace lvh::detail::windows_broker_service { message = "The license service response did not include trusted server time."; return backend_failure; } - const auto *benefit = polar_benefit(state.benefit_id); - if (benefit == nullptr) { + if (const auto *benefit = polar_benefit(state.benefit_id); benefit == nullptr) { message = "License organization or benefit is not allowed for this driver."; return license_invalid; } - const auto server_timestamp = license_calendar_timestamp(*server_time); - if (benefit->expiration_required) { - const auto expiration = parse_rfc3339_timestamp(state.expires_at); - if (!expiration) { - message = "The yearly license has an invalid expiration."; - return backend_failure; - } - if (server_timestamp >= license_calendar_timestamp(*expiration)) { - message = "The yearly license has expired."; - return license_invalid; - } - } - state.validated_at = server_timestamp; + state.validated_at = license_calendar_timestamp(*server_time); state.boot_marker = boot_session_marker_; state.validated_uptime_ms = ::GetTickCount64(); if (state.boot_marker.empty()) { @@ -2048,7 +1927,7 @@ namespace lvh::detail::windows_broker_service { ); } - bool license_time_is_current_locked() const { + bool license_authorization_is_current_locked() const { if (!license_state_ || license_state_->license_status != "granted") { return false; } @@ -2056,30 +1935,29 @@ namespace lvh::detail::windows_broker_service { if (benefit == nullptr) { return false; } - if (!benefit->expiration_required) { + if (!benefit->subscription_backed) { return true; } const auto effective_timestamp = license_effective_timestamp_locked(); - return effective_timestamp && license_expiration_is_current( - license_state_->expires_at, - true, + return effective_timestamp && license_validation_is_current( + license_state_->validated_at, *effective_timestamp ); } - bool license_expiration_has_elapsed_locked() const { + bool subscription_validation_has_elapsed_locked() const { if (!license_state_) { return false; } const auto *benefit = polar_benefit(license_state_->benefit_id); const auto effective_timestamp = license_effective_timestamp_locked(); - return benefit != nullptr && benefit->expiration_required && - effective_timestamp && - !license_expiration_is_current( - license_state_->expires_at, - true, - *effective_timestamp - ); + if (benefit == nullptr || !benefit->subscription_backed || !effective_timestamp.has_value()) { + return false; + } + return !license_validation_is_current( + license_state_->validated_at, + *effective_timestamp + ); } void mark_license_validation_unavailable() { @@ -2104,7 +1982,7 @@ namespace lvh::detail::windows_broker_service { bool license_is_active_locked() const { return license_state_ && license_allowed(*license_state_) && - license_time_is_current_locked(); + license_authorization_is_current_locked(); } void invalidate_license() { @@ -2279,7 +2157,7 @@ namespace lvh::detail::windows_broker_service { return {LvhWindowsBrokerStatusCode::success, true}; } - if (!license_allowed(*license_state_) || !license_time_is_current_locked()) { + if (!license_allowed(*license_state_) || !license_authorization_is_current_locked()) { fill_license_status_locked(license); copy_c_string(message, license.message.data()); return {LvhWindowsBrokerStatusCode::license_invalid, false}; @@ -2314,7 +2192,6 @@ namespace lvh::detail::windows_broker_service { license.activation_limit = 0; license.activation_usage = 0; copy_c_string(license.customer_email, ""); - copy_c_string(license.expires_at, ""); if (!github_actions_) { copy_c_string(license.plan_name, "Unlicensed"); copy_c_string(license.message, "An active license is required to create gamepads."); @@ -2349,7 +2226,6 @@ namespace lvh::detail::windows_broker_service { const auto plan_name = plan_name_for_benefit(license_state_->benefit_id); copy_c_string(license.plan_name, plan_name.empty() ? "Licensed" : plan_name); copy_c_string(license.customer_email, license_state_->customer_email); - copy_c_string(license.expires_at, license_state_->expires_at); if (!license_allowed(*license_state_)) { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); @@ -2360,12 +2236,12 @@ namespace lvh::detail::windows_broker_service { } else if (license_state_->license_status == "revoked") { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); copy_c_string(license.message, "License revoked."); - } else if (!license_time_is_current_locked()) { + } else if (!license_authorization_is_current_locked()) { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); if (!license_effective_timestamp_locked().has_value()) { - copy_c_string(license.message, "Reconnect to Polar after Windows restarts to verify the yearly expiration."); + copy_c_string(license.message, "Reconnect to Polar after Windows restarts to validate the license."); } else { - copy_c_string(license.message, "The yearly license has expired."); + copy_c_string(license.message, "Reconnect to Polar to validate the yearly subscription."); } } else if (license_state_->license_status == "granted") { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::licensed); diff --git a/src/platform/windows/shared/lvh_windows_broker_config.hpp b/src/platform/windows/shared/lvh_windows_broker_config.hpp index 4b5b4a6..ebd3ed6 100644 --- a/src/platform/windows/shared/lvh_windows_broker_config.hpp +++ b/src/platform/windows/shared/lvh_windows_broker_config.hpp @@ -17,7 +17,7 @@ namespace lvh::windows::broker_config { struct PolarBenefit { std::string_view id; std::string_view plan_name; - bool expiration_required; + bool subscription_backed; }; // Polar's public license API identifies the organization and license-key benefit, @@ -29,12 +29,12 @@ namespace lvh::windows::broker_config { PolarBenefit { .id = "eb316dac-bf6a-4359-95a2-86c299d48ecc", .plan_name = "Yearly", - .expiration_required = true, + .subscription_backed = true, }, PolarBenefit { .id = "157374cb-f526-4154-81ba-9f2c92a053ca", .plan_name = "Lifetime", - .expiration_required = false, + .subscription_backed = false, }, }; diff --git a/src/platform/windows/shared/lvh_windows_broker_protocol.h b/src/platform/windows/shared/lvh_windows_broker_protocol.h index a7238f7..2f2f5fa 100644 --- a/src/platform/windows/shared/lvh_windows_broker_protocol.h +++ b/src/platform/windows/shared/lvh_windows_broker_protocol.h @@ -10,13 +10,12 @@ #include #include -inline constexpr uint32_t LVH_WINDOWS_BROKER_PROTOCOL_VERSION = 2u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_PROTOCOL_VERSION = 3u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE = 512u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_LICENSE_KEY_SIZE = 128u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_INSTANCE_NAME_SIZE = 128u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_PLAN_NAME_SIZE = 128u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_CUSTOMER_EMAIL_SIZE = 128u; -inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_TIMESTAMP_SIZE = 64u; inline constexpr char LVH_WINDOWS_BROKER_PIPE_PATH[] = R"(\\.\pipe\libvirtualhid-broker)"; enum class LvhWindowsBrokerRequestType : uint32_t { @@ -66,7 +65,6 @@ struct LvhWindowsBrokerLicenseStatus { uint32_t activation_usage; std::array plan_name; std::array customer_email; - std::array expires_at; std::array message; }; diff --git a/src/platform/windows/windows_license.cpp b/src/platform/windows/windows_license.cpp index f3fa93c..f1825c1 100644 --- a/src/platform/windows/windows_license.cpp +++ b/src/platform/windows/windows_license.cpp @@ -46,7 +46,6 @@ namespace lvh { .activation_usage = status.activation_usage, .plan_name = status.plan_name.data(), .customer_email = status.customer_email.data(), - .expires_at = status.expires_at.data(), .message = status.message.data(), .purchase_url = std::string {windows::broker_config::buy_url}, .manage_account_url = std::string {windows::broker_config::manage_account_url}, diff --git a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp index aaf8d7c..579bd93 100644 --- a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp @@ -54,10 +54,15 @@ namespace lvh::detail::test { BrokerPolarResult broker_polar_scenario(BrokerPolarScenario scenario); + struct BrokerSubscriptionValidationResult { + bool yearly_benefit_is_subscription_backed = false; + bool subscription_validation_before_deadline_is_current = false; + bool subscription_validation_at_deadline_is_stale = false; + }; + + BrokerSubscriptionValidationResult broker_subscription_validation_policy(); + struct BrokerLicenseFallbackResult { - bool before_boundary_is_current = false; - bool boundary_is_current = false; - bool lifetime_is_current = false; bool same_boot_anchor_is_accepted = false; bool changed_boot_anchor_is_rejected = false; bool uptime_rollback_is_rejected = false; diff --git a/tests/fixtures/windows_broker_service_test_hooks.cpp b/tests/fixtures/windows_broker_service_test_hooks.cpp index ffe6260..6ada0ca 100644 --- a/tests/fixtures/windows_broker_service_test_hooks.cpp +++ b/tests/fixtures/windows_broker_service_test_hooks.cpp @@ -485,10 +485,32 @@ namespace lvh::detail::test { }; } + BrokerSubscriptionValidationResult broker_subscription_validation_policy() { + using namespace lvh::detail::windows_broker_service; + const auto subscription_validation_seconds = static_cast( + std::chrono::duration_cast( + subscription_validation_max_age + ) + .count() + ); + return { + .yearly_benefit_is_subscription_backed = + lvh::windows::broker_config::allowed_benefits.front().subscription_backed, + .subscription_validation_before_deadline_is_current = + license_validation_is_current( + 1000U, + 1000U + subscription_validation_seconds - 1U + ), + .subscription_validation_at_deadline_is_stale = + !license_validation_is_current( + 1000U, + 1000U + subscription_validation_seconds + ), + }; + } + BrokerLicenseFallbackResult broker_license_fallback_policy() { using namespace lvh::detail::windows_broker_service; - const auto expiration = parse_rfc3339_timestamp("2026-01-03T00:00:00Z"); - const auto expiration_timestamp = license_calendar_timestamp(*expiration); PolarLicenseState persisted_state { .provider = "polar", .validated_at = 12345U, @@ -517,17 +539,6 @@ namespace lvh::detail::test { true ); return { - .before_boundary_is_current = license_expiration_is_current( - "2026-01-03T00:00:00Z", - true, - expiration_timestamp - 1U - ), - .boundary_is_current = license_expiration_is_current( - "2026-01-03T00:00:00Z", - true, - expiration_timestamp - ), - .lifetime_is_current = license_expiration_is_current({}, false, 0U), .same_boot_anchor_is_accepted = monotonic_timestamp.has_value(), .changed_boot_anchor_is_rejected = !changed_boot_timestamp.has_value(), .uptime_rollback_is_rejected = !uptime_rollback_timestamp.has_value(), diff --git a/tests/unit/test_license.cpp b/tests/unit/test_license.cpp index 97fa2f1..d31606b 100644 --- a/tests/unit/test_license.cpp +++ b/tests/unit/test_license.cpp @@ -79,20 +79,20 @@ TEST(WindowsBrokerClientTest, BuildsVersionedRequestHeader) { EXPECT_EQ(header.size, sizeof(LvhWindowsBrokerLicenseRequest)); EXPECT_EQ(header.type, static_cast(LvhWindowsBrokerRequestType::validate_license)); EXPECT_EQ(header.reserved0, 0U); - EXPECT_EQ(LVH_WINDOWS_BROKER_PROTOCOL_VERSION, 2U); + EXPECT_EQ(LVH_WINDOWS_BROKER_PROTOCOL_VERSION, 3U); } TEST(WindowsBrokerClientTest, PreservesFixedWireLayout) { EXPECT_EQ(sizeof(LvhWindowsBrokerRequestHeader), 16U); - EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseStatus), 860U); + EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseStatus), 796U); EXPECT_EQ(sizeof(LvhWindowsBrokerStatusRequest), 16U); - EXPECT_EQ(sizeof(LvhWindowsBrokerStatusResponse), 1388U); + EXPECT_EQ(sizeof(LvhWindowsBrokerStatusResponse), 1324U); EXPECT_EQ(sizeof(LvhWindowsBrokerCreateGamepadRequest), 2528U); - EXPECT_EQ(sizeof(LvhWindowsBrokerCreateGamepadResponse), 1704U); + EXPECT_EQ(sizeof(LvhWindowsBrokerCreateGamepadResponse), 1640U); EXPECT_EQ(sizeof(LvhWindowsBrokerDestroyDeviceRequest), 64U); - EXPECT_EQ(sizeof(LvhWindowsBrokerDestroyDeviceResponse), 1388U); + EXPECT_EQ(sizeof(LvhWindowsBrokerDestroyDeviceResponse), 1324U); EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseRequest), 272U); - EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseResponse), 1388U); + EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseResponse), 1324U); } TEST(WindowsBrokerClientTest, MapsLicenseAndTransportStatuses) { diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp index 935dc9c..e9c465e 100644 --- a/tests/unit/test_virtualhid_control_model.cpp +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -76,6 +76,12 @@ TEST(VirtualHidControlModelTest, NamesKnownAndFallbackEnumValues) { EXPECT_EQ(control::yes_no(false), L"no"); } +TEST(VirtualHidControlModelTest, NormalizesPastedLicenseKeys) { + EXPECT_EQ(control::normalized_license_key("test-license-key"), "test-license-key"); + EXPECT_EQ(control::normalized_license_key("\r\n test-license-key \t"), "test-license-key"); + EXPECT_TRUE(control::normalized_license_key(" \r\n\t").empty()); +} + TEST(VirtualHidControlModelTest, MapsProfileChoicesToProfiles) { for (const auto &choice : control::profile_choices) { const auto profile = control::profile_for_choice(choice); diff --git a/tests/unit/test_windows_broker_service.cpp b/tests/unit/test_windows_broker_service.cpp index dac7140..664986b 100644 --- a/tests/unit/test_windows_broker_service.cpp +++ b/tests/unit/test_windows_broker_service.cpp @@ -397,11 +397,15 @@ TEST(WindowsBrokerImplementationTest, ReportsDpapiAndPolarFailures) { EXPECT_EQ(succeeded.receive_timeout, 10000); } +TEST(WindowsBrokerImplementationTest, BoundsOfflineSubscriptionValidation) { + const auto policy = lvh::detail::test::broker_subscription_validation_policy(); + EXPECT_TRUE(policy.yearly_benefit_is_subscription_backed); + EXPECT_TRUE(policy.subscription_validation_before_deadline_is_current); + EXPECT_TRUE(policy.subscription_validation_at_deadline_is_stale); +} + TEST(WindowsBrokerImplementationTest, EnforcesLimitedUnvalidatedFallback) { const auto policy = lvh::detail::test::broker_license_fallback_policy(); - EXPECT_TRUE(policy.before_boundary_is_current); - EXPECT_FALSE(policy.boundary_is_current); - EXPECT_TRUE(policy.lifetime_is_current); EXPECT_TRUE(policy.same_boot_anchor_is_accepted); EXPECT_TRUE(policy.changed_boot_anchor_is_rejected); EXPECT_TRUE(policy.uptime_rollback_is_rejected); diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 830c54b..06b7f4d 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -39,6 +39,7 @@ namespace { using lvh::tools::virtualhid_control::button_choices; using lvh::tools::virtualhid_control::device_type_name; using lvh::tools::virtualhid_control::node_kind_name; + using lvh::tools::virtualhid_control::normalized_license_key; using lvh::tools::virtualhid_control::output_kind_name; using lvh::tools::virtualhid_control::output_summary; using lvh::tools::virtualhid_control::OutputLogEntry; @@ -220,7 +221,6 @@ namespace { bool licensed = false; std::string plan_name = "Unavailable"; std::string customer_email; - std::string expires_at; std::string state_text = "License broker unavailable."; std::string message; std::string purchase_url; @@ -275,7 +275,6 @@ namespace { snapshot.licensed = status.licensed(); snapshot.plan_name = status.plan_name; snapshot.customer_email = status.customer_email; - snapshot.expires_at = status.expires_at; snapshot.message = status.message.empty() ? result.status.message() : status.message; snapshot.purchase_url = status.purchase_url; snapshot.manage_account_url = status.manage_account_url; @@ -297,9 +296,6 @@ namespace { if (status.activation_limit > 0U) { snapshot.state_text += std::format(" | machine limit {}", status.activation_limit); } - if (!snapshot.expires_at.empty()) { - snapshot.state_text += " | expires " + snapshot.expires_at; - } return snapshot; } @@ -334,9 +330,10 @@ namespace { ImGui::TextUnformatted("License key"); ImGui::InputText("##license-key", license_key_input_.data(), license_key_input_.size()); { - ScopedDisabled disabled {!snapshot_.broker_available || license_key_input_[0] == '\0'}; + const auto license_key = normalized_license_key(license_key_input_.data()); + ScopedDisabled disabled {license_key.empty()}; if (ImGui::Button("Activate license", {-FLT_MIN, 0.0F})) { - activate(show_error); + activate(license_key, show_error); } } { @@ -404,9 +401,9 @@ namespace { #if defined(_WIN32) template - void activate(ErrorHandler &show_error) { + void activate(std::string_view license_key, ErrorHandler &show_error) { std::string error; - apply_result(lvh::activate_license(license_key_input_.data()), error); + apply_result(lvh::activate_license(license_key), error); if (!error.empty()) { show_error(error); } else { diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp index bdcd832..d5eb8af 100644 --- a/tools/virtualhid_control_model.cpp +++ b/tools/virtualhid_control_model.cpp @@ -5,8 +5,10 @@ // standard includes #include +#include #include #include +#include #include // local includes @@ -157,6 +159,15 @@ namespace lvh::tools::virtualhid_control { return value ? L"yes" : L"no"; } + std::string normalized_license_key(std::string_view license_key) { + const auto is_whitespace = [](char character) { + return std::isspace(static_cast(character)) != 0; + }; + const auto first = std::ranges::find_if_not(license_key, is_whitespace); + const auto last = std::ranges::find_if_not(std::views::reverse(license_key), is_whitespace).base(); + return first == license_key.end() ? std::string {} : std::string {first, last}; + } + bool supports_normalized_feedback(const DeviceProfile &profile) { using enum GamepadOutputKind; diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp index ca37476..502a160 100644 --- a/tools/virtualhid_control_model.hpp +++ b/tools/virtualhid_control_model.hpp @@ -123,6 +123,15 @@ namespace lvh::tools::virtualhid_control { int trigger_to_slider(float value); float slider_to_float(long value); std::wstring yes_no(bool value); + + /** + * @brief Trim surrounding whitespace from a pasted license key. + * + * @param license_key License-key text from the control UI input buffer. + * @return A normalized key, or an empty string when the input has no key. + */ + std::string normalized_license_key(std::string_view license_key); + bool supports_normalized_feedback(const DeviceProfile &profile); std::wstring profile_feature_summary(const DeviceProfile &profile); bool append_latest_output_summary(std::wostringstream &stream, const OutputState &state);