diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index f2d4ad1..8930553 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -189,6 +189,9 @@ gamepad while authorization remains current. Existing virtual gamepads close when their authorization expires or is revoked. A yearly activation needs online validation after the broker restarts; this deliberately fails closed when trusted elapsed time cannot be reconstructed. +Successful licensed gamepad creations are saved as pending usage and reported +through Polar's `increment_usage` field on the next license validation. +Validation with no new gamepads leaves Polar usage unchanged. If creation returns `backend_unavailable`, inspect the launchd job with `sudo launchctl print system/dev.lizardbyte.app.libvirtualhid`. If it returns diff --git a/docs/usage.md b/docs/usage.md index 12883b2..9d1e8a3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -135,6 +135,15 @@ a yearly subscription must reconnect to Polar before device creation; a lifetime license can use the one-device outage fallback. A confirmed missing, revoked, disabled, or mismatched entitlement invalidates the license and removes all licensed virtual HID devices. +Each successful licensed gamepad creation adds one pending Polar usage unit. +The broker saves the pending count locally and sends it as `increment_usage` +with its next successful license validation (normally within 24 hours, or on +manual refresh). Routine validations do not increment usage when no gamepad +was created. Keyboard and mouse creation and GitHub Actions evaluation do not +count. Reporting is best effort: a lost response after Polar accepts an +increment can cause a later retry to count it twice, while a local state-write +failure or license deactivation before a pending count is sent can lose counts. +Do not use Polar's usage limit as an exact device-creation quota. 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. diff --git a/docs/windows-driver.md b/docs/windows-driver.md index ac71475..da8edfc 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -411,6 +411,9 @@ portal URL changes, then rebuild the Windows package. No Polar access token or webhook secret is compiled into the client: activation, validation, and deactivation use Polar's [public customer license-key API](https://polar.sh/docs/features/benefits/license-keys). +Successful licensed gamepad creations are counted in the protected broker state +and reported through `increment_usage` on the next license validation. The +create request still completes without an online Polar request. Those requests pin Polar's date-based API contract to `2026-04` with the `Polar-Version` header. Before Polar removes that version, update `polar_request_headers` in diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index 34d5fce..804665f 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -332,7 +332,8 @@ namespace lvh::detail::macos_broker { } Message response; bool evaluation = false; - if (!licenses.authorize_create(response, evaluation)) { + std::string authorized_key; + if (!licenses.authorize_create(response, evaluation, authorized_key)) { static_cast(send_message(fd, response)); ::close(fd); return; @@ -346,7 +347,7 @@ namespace lvh::detail::macos_broker { ::close(fd); return; } - licenses.add_device(evaluation); + licenses.add_device(evaluation, authorized_key); response.type = MessageType::response; response.status = 0; static_cast(session.send(response)); diff --git a/src/platform/macos/broker/license_manager.hpp b/src/platform/macos/broker/license_manager.hpp index bccb3d8..b0ea1e7 100644 --- a/src/platform/macos/broker/license_manager.hpp +++ b/src/platform/macos/broker/license_manager.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include namespace lvh::detail::macos_broker { @@ -27,9 +28,9 @@ namespace lvh::detail::macos_broker { LicenseManager &operator=(const LicenseManager &) = delete; Message handle(const Message &request); - bool authorize_create(Message &response, bool &evaluation); + bool authorize_create(Message &response, bool &evaluation, std::string &authorized_key); bool device_is_authorized(bool evaluation); - void add_device(bool evaluation); + void add_device(bool evaluation, std::string_view authorized_key); void remove_device(bool evaluation); private: @@ -41,6 +42,7 @@ namespace lvh::detail::macos_broker { std::string benefit_id; std::string customer_email; std::uint32_t activation_limit = 0; + std::uint32_t pending_usage = 0; }; Message activate(const Message &request); @@ -48,6 +50,7 @@ namespace lvh::detail::macos_broker { Message deactivate(); Message status(); bool licensed_locked() const; + bool save_state(const State &state) const; void fill_status_locked(Message &response) const; void background_validation(std::stop_token stop); diff --git a/src/platform/macos/broker/license_manager.mm b/src/platform/macos/broker/license_manager.mm index 4ca84f8..b54622b 100644 --- a/src/platform/macos/broker/license_manager.mm +++ b/src/platform/macos/broker/license_manager.mm @@ -15,6 +15,7 @@ #include #import #include +#include #include #include #include @@ -178,6 +179,7 @@ bool valid_c_string(const std::array &value) { state.benefit_id = from_ns(json_string(saved, @"benefit_id")); state.customer_email = from_ns(json_string(saved, @"customer_email")); state.activation_limit = [saved[@"activation_limit"] unsignedIntValue]; + state.pending_usage = [saved[@"pending_usage"] unsignedIntValue]; if (!state.key.empty() && !state.activation_id.empty() && state.status == "granted" && state.organization_id == broker_license::polar_organization_id && broker_license::benefit(state.benefit_id)) { state_ = std::move(state); } @@ -217,6 +219,19 @@ bool valid_c_string(const std::array &value) { (validated_at_ && std::chrono::steady_clock::now() - *validated_at_ < broker_license::subscription_max_age); } + bool LicenseManager::save_state(const State &state) const { + @autoreleasepool { + return write_protected_json(state_path, @{@"key": to_ns(state.key), + @"activation_id": to_ns(state.activation_id), + @"status": to_ns(state.status), + @"organization_id": to_ns(state.organization_id), + @"benefit_id": to_ns(state.benefit_id), + @"customer_email": to_ns(state.customer_email), + @"activation_limit": @(state.activation_limit), + @"pending_usage": @(state.pending_usage)}); + } + } + void LicenseManager::fill_status_locked(Message &response) const { response.active_devices = active_devices_; if (!state_) { @@ -292,13 +307,13 @@ bool valid_c_string(const std::array &value) { set_text(response.message, "License organization, benefit, or activation is not allowed"); return response; } - if (!write_protected_json(state_path, @{@"key": to_ns(state.key), - @"activation_id": to_ns(state.activation_id), - @"status": to_ns(state.status), - @"organization_id": to_ns(state.organization_id), - @"benefit_id": to_ns(state.benefit_id), - @"customer_email": to_ns(state.customer_email), - @"activation_limit": @(state.activation_limit)})) { + { + std::lock_guard lock {mutex_}; + if (state_ && state_->key == state.key) { + state.pending_usage = state_->pending_usage; + } + } + if (!save_state(state)) { response.status = static_cast(ErrorCode::backend_failure); set_text(response.message, "Unable to securely save machine license"); return response; @@ -332,11 +347,15 @@ bool valid_c_string(const std::array &value) { state = *state_; } @autoreleasepool { - auto result = polar_request(@"/v1/customer-portal/license-keys/validate", @ { + NSMutableDictionary *request_body = [@ { @"key": to_ns(state.key), @"organization_id": to_ns(broker_license::polar_organization_id), @"activation_id": to_ns(state.activation_id) - }); + } mutableCopy]; + if (state.pending_usage != 0) { + request_body[@"increment_usage"] = @(state.pending_usage); + } + auto result = polar_request(@"/v1/customer-portal/license-keys/validate", request_body); if (!result.transport_ok || result.status != 200 || !result.body || !result.trusted_time) { { std::lock_guard lock {mutex_}; @@ -378,17 +397,12 @@ bool valid_c_string(const std::array &value) { state.status = new_status; state.benefit_id = new_benefit; state.activation_limit = [result.body[@"limit_activations"] unsignedIntValue]; + state.pending_usage = 0; NSDictionary *customer = result.body[@"customer"]; if ([customer isKindOfClass:[NSDictionary class]]) { state.customer_email = from_ns(json_string(customer, @"email")); } - if (!write_protected_json(state_path, @{@"key": to_ns(state.key), - @"activation_id": to_ns(state.activation_id), - @"status": to_ns(state.status), - @"organization_id": to_ns(state.organization_id), - @"benefit_id": to_ns(state.benefit_id), - @"customer_email": to_ns(state.customer_email), - @"activation_limit": @(state.activation_limit)})) { + if (!save_state(state)) { auto response = status(); response.status = static_cast(ErrorCode::backend_failure); set_text(response.message, "Unable to securely save validated license"); @@ -466,13 +480,14 @@ bool valid_c_string(const std::array &value) { } } - bool LicenseManager::authorize_create(Message &response, bool &evaluation) { + bool LicenseManager::authorize_create(Message &response, bool &evaluation, std::string &authorized_key) { response.type = MessageType::response; std::lock_guard lock {mutex_}; fill_status_locked(response); if (licensed_locked()) { if (online_confirmed_ || active_licensed_devices_ == 0) { evaluation = false; + authorized_key = state_->key; return true; } response.status = static_cast(ErrorCode::network_unavailable); @@ -516,11 +531,22 @@ bool valid_c_string(const std::array &value) { return !unavailable_since_ || !broker_license::outage_retention_elapsed(std::chrono::steady_clock::now() - *unavailable_since_); } - void LicenseManager::add_device(bool evaluation) { - std::lock_guard lock {mutex_}; - ++active_devices_; - if (!evaluation) { - ++active_licensed_devices_; + void LicenseManager::add_device(bool evaluation, std::string_view authorized_key) { + std::lock_guard operation_lock {operation_mutex_}; + std::optional state; + { + std::lock_guard lock {mutex_}; + ++active_devices_; + if (!evaluation) { + ++active_licensed_devices_; + if (state_ && state_->key == authorized_key && state_->pending_usage < std::numeric_limits::max()) { + ++state_->pending_usage; + state = *state_; + } + } + } + if (state) { + static_cast(save_state(*state)); } } diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp index 00b64e8..c5e704d 100644 --- a/src/platform/windows/broker/libvirtualhid_broker.cpp +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -633,6 +633,7 @@ namespace lvh::detail::windows_broker_service { std::string benefit_id; std::string customer_email; std::uint32_t activation_limit = 0; + std::uint32_t pending_usage = 0; // Audit timestamp supplied by Polar, not the local machine clock. std::uint64_t validated_at = 0; // The boot-session marker and uptime at validation let the broker advance @@ -641,6 +642,18 @@ namespace lvh::detail::windows_broker_service { std::uint64_t validated_uptime_ms = 0; }; + nlohmann::json polar_validation_body(const PolarLicenseState &state) { + nlohmann::json body { + {"key", state.license_key}, + {"organization_id", std::string {lvh::windows::broker_config::polar_organization_id}}, + {"activation_id", state.activation_id}, + }; + if (state.pending_usage != 0U) { + body["increment_usage"] = state.pending_usage; + } + return body; + } + std::string serialize_license_state(const PolarLicenseState &state) { std::ostringstream serialized; serialized << "provider=" << state.provider << "\n"; @@ -652,6 +665,7 @@ namespace lvh::detail::windows_broker_service { serialized << "benefit_id=" << state.benefit_id << "\n"; serialized << "customer_email=" << state.customer_email << "\n"; serialized << "activation_limit=" << state.activation_limit << "\n"; + serialized << "pending_usage=" << state.pending_usage << "\n"; serialized << "validated_at=" << state.validated_at << "\n"; serialized << "boot_marker=" << state.boot_marker << "\n"; serialized << "validated_uptime_ms=" << state.validated_uptime_ms << "\n"; @@ -791,6 +805,11 @@ namespace lvh::detail::windows_broker_service { state.customer_email = value; } else if (key == "activation_limit") { state.activation_limit = static_cast(parse_uint64(value).value_or(0)); + } else if (key == "pending_usage") { + state.pending_usage = static_cast(std::min( + parse_uint64(value).value_or(0), + static_cast(std::numeric_limits::max()) + )); } else if (key == "validated_at") { state.validated_at = parse_uint64(value).value_or(0); } else if (key == "boot_marker") { @@ -1389,9 +1408,11 @@ namespace lvh::detail::windows_broker_service { return response; } + std::string authorized_license_id; const auto [authorization_status, github_actions_evaluation] = authorize_device_create( response.license, - response.message + response.message, + authorized_license_id ); if (authorization_status != LvhWindowsBrokerStatusCode::success) { response.status = std::to_underlying(authorization_status); @@ -1471,6 +1492,9 @@ namespace lvh::detail::windows_broker_service { } response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + if (!github_actions_evaluation && request.device.device_type == LVH_WINDOWS_DEVICE_GAMEPAD) { + record_gamepad_creation(authorized_license_id); + } copy_c_string( response.message, github_actions_evaluation ? @@ -1685,6 +1709,13 @@ namespace lvh::detail::windows_broker_service { return response; } + { + std::lock_guard lock {mutex_}; + if (license_state_ && license_state_->license_key_id == new_state.license_key_id) { + new_state.pending_usage = license_state_->pending_usage; + } + } + std::string authorization_error; if (const auto authorization_result = accept_trusted_license_time(new_state, api_result.server_time, authorization_error); authorization_result != TrustedLicenseTimeResult::success) { response.status = std::to_underlying( @@ -2031,6 +2062,21 @@ namespace lvh::detail::windows_broker_service { )); } + void record_gamepad_creation(std::string_view authorized_license_id) { + std::lock_guard operation_lock {license_operation_mutex_}; + PolarLicenseState state; + { + std::lock_guard lock {mutex_}; + if (!license_state_ || license_state_->license_key_id != authorized_license_id || license_state_->pending_usage == std::numeric_limits::max()) { + return; + } + ++license_state_->pending_usage; + state = *license_state_; + } + std::string save_error; + static_cast(save_license_state(state, save_error)); + } + bool license_is_active_locked() const { return license_state_ && license_allowed(*license_state_) && @@ -2066,11 +2112,7 @@ namespace lvh::detail::windows_broker_service { auto api_result = post_polar_license_request( L"/v1/customer-portal/license-keys/validate", - nlohmann::json { - {"key", state.license_key}, - {"organization_id", std::string {lvh::windows::broker_config::polar_organization_id}}, - {"activation_id", state.activation_id}, - } + polar_validation_body(state) ); if (!api_result.transport_ok) { mark_license_validation_unavailable(); @@ -2167,7 +2209,8 @@ namespace lvh::detail::windows_broker_service { std::pair authorize_device_create( LvhWindowsBrokerLicenseStatus &license, - std::array &message + std::array &message, + std::string &authorized_license_id ) { { std::lock_guard lock {mutex_}; @@ -2215,6 +2258,8 @@ namespace lvh::detail::windows_broker_service { return {LvhWindowsBrokerStatusCode::license_invalid, false}; } + authorized_license_id = license_state_->license_key_id; + if (license_online_confirmed_) { fill_license_status_locked(license); copy_c_string(message, "License validated online."); 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 e75c922..1649d7a 100644 --- a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp @@ -55,6 +55,14 @@ namespace lvh::detail::test { BrokerPolarResult broker_polar_scenario(BrokerPolarScenario scenario); + struct BrokerUsageResult { + bool pending_usage_round_trips = false; + bool routine_validation_omits_increment = false; + bool pending_validation_increments_usage = false; + }; + + BrokerUsageResult broker_usage_policy(); + struct BrokerSubscriptionValidationResult { bool yearly_benefit_is_subscription_backed = false; bool subscription_validation_before_deadline_is_current = false; diff --git a/tests/fixtures/windows_broker_service_test_hooks.cpp b/tests/fixtures/windows_broker_service_test_hooks.cpp index 1a50338..1f40335 100644 --- a/tests/fixtures/windows_broker_service_test_hooks.cpp +++ b/tests/fixtures/windows_broker_service_test_hooks.cpp @@ -498,6 +498,26 @@ namespace lvh::detail::test { }; } + BrokerUsageResult broker_usage_policy() { + using namespace lvh::detail::windows_broker_service; + PolarLicenseState state { + .license_key = "test-key", + .activation_id = "test-activation", + .pending_usage = 2U, + }; + const auto restored = deserialize_license_state(serialize_license_state(state)); + const auto pending_body = polar_validation_body(restored); + state.pending_usage = 0U; + const auto routine_body = polar_validation_body(state); + return { + .pending_usage_round_trips = restored.pending_usage == 2U, + .routine_validation_omits_increment = !routine_body.contains("increment_usage"), + .pending_validation_increments_usage = + pending_body.value("increment_usage", 0U) == 2U && + pending_body.value("activation_id", std::string {}) == "test-activation", + }; + } + BrokerSubscriptionValidationResult broker_subscription_validation_policy() { using namespace lvh::detail::windows_broker_service; const auto subscription_validation_seconds = static_cast( diff --git a/tests/unit/test_windows_broker_service.cpp b/tests/unit/test_windows_broker_service.cpp index e2b86f7..f2334e5 100644 --- a/tests/unit/test_windows_broker_service.cpp +++ b/tests/unit/test_windows_broker_service.cpp @@ -405,6 +405,13 @@ TEST(WindowsBrokerImplementationTest, BoundsOfflineSubscriptionValidation) { EXPECT_TRUE(policy.subscription_validation_at_deadline_is_stale); } +TEST(WindowsBrokerImplementationTest, ReportsOnlyPendingGamepadUsage) { + const auto result = lvh::detail::test::broker_usage_policy(); + EXPECT_TRUE(result.pending_usage_round_trips); + EXPECT_TRUE(result.routine_validation_omits_increment); + EXPECT_TRUE(result.pending_validation_increments_usage); +} + TEST(WindowsBrokerImplementationTest, EnforcesLimitedUnvalidatedFallback) { const auto policy = lvh::detail::test::broker_license_fallback_policy(); EXPECT_TRUE(policy.same_boot_anchor_is_accepted);