Skip to main content

keylight/
client.rs

1//! The [`Keylight`] client: activation, validation, deactivation, offline state
2//! resolution, trials, the keyless beacon, refresh timing, and lifecycle events.
3
4use crate::clock::{clock_manipulated, clock_rolled_back};
5use crate::http::retry::{MAX_ATTEMPTS, RetryDecision, backoff_ms, clamp_sleep_ms, decide};
6use crate::http::{Transport, TransportOutcome, ureq_transport::UreqTransport};
7use crate::state::{KeylessState, LicenseState, TrialStatus, resolve_state};
8use crate::store::device::{DeviceIdentity, SystemDeviceIdentity};
9use crate::store::{LicenseStore, account, encrypted_file::EncryptedFileStore};
10use crate::{KeylightConfig, KeylightError, Lease, Result, telemetry, verify_lease};
11use serde::Deserialize;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15#[derive(Debug, Clone)]
16pub struct ActivationResult {
17    pub activated: bool,
18    pub instance_id: Option<String>,
19    pub lease: Option<Lease>,
20    pub license_expires_at: Option<i64>,
21    pub error: Option<String>,
22}
23#[derive(Debug, Clone)]
24pub struct ValidationResult {
25    pub valid: bool,
26    pub lease: Option<Lease>,
27    pub license_expires_at: Option<i64>,
28    pub error: Option<String>,
29}
30
31#[derive(Deserialize)]
32struct ActivateResp {
33    activated: bool,
34    instance_id: Option<String>,
35    license_expires_at: Option<i64>,
36    lease: Option<Lease>,
37    error: Option<String>,
38}
39#[derive(Deserialize)]
40struct ValidateResp {
41    /// Defaults to `false` when absent: the real worker's revoked /
42    /// instance-not-active response is `{"error": "..."}` with no `valid`
43    /// field at all, and that must be treated as a definitive rejection,
44    /// not fail to deserialize.
45    #[serde(default)]
46    valid: bool,
47    license_expires_at: Option<i64>,
48    lease: Option<Lease>,
49    error: Option<String>,
50}
51#[derive(Deserialize)]
52struct ErrorResp {
53    error: Option<String>,
54}
55
56pub struct Keylight {
57    config: KeylightConfig,
58    store: Arc<dyn LicenseStore>,
59    transport: Arc<dyn Transport>,
60    device: Arc<dyn DeviceIdentity>,
61    on_event: Option<Box<dyn Fn(crate::state::LicenseLifecycleEvent) + Send + Sync>>,
62    /// Debounce anchor for [`Keylight::active_revalidate`] — monotonic, so a
63    /// wall-clock change can neither stretch nor shrink the window. See that
64    /// method for why the window is deliberately per-process and unpersisted.
65    last_active_revalidate_at: Mutex<Option<Instant>>,
66}
67
68impl Keylight {
69    /// Construct with the default encrypted-file store + ureq transport.
70    pub fn new(config: KeylightConfig) -> Result<Self> {
71        let ns = format!("{}-{}", config.tenant_id, config.product_id);
72        let store = Arc::new(EncryptedFileStore::new(&ns)?);
73        Ok(Self::with_parts(
74            config,
75            store,
76            Arc::new(UreqTransport::default()),
77        ))
78    }
79    /// Construct with custom store + transport (tests, alternate backends).
80    pub fn with_parts(
81        config: KeylightConfig,
82        store: Arc<dyn LicenseStore>,
83        transport: Arc<dyn Transport>,
84    ) -> Self {
85        Self {
86            config,
87            store,
88            transport,
89            device: Arc::new(SystemDeviceIdentity),
90            on_event: None,
91            last_active_revalidate_at: Mutex::new(None),
92        }
93    }
94    /// Register a handler invoked when the resolved license state crosses a lifecycle transition.
95    pub fn with_event_handler(
96        mut self,
97        handler: impl Fn(crate::state::LicenseLifecycleEvent) + Send + Sync + 'static,
98    ) -> Self {
99        self.on_event = Some(Box::new(handler));
100        self
101    }
102    /// Override the device identity used for `machine_hash` on the keyless heartbeat
103    /// (tests, alternate platforms). Defaults to [`SystemDeviceIdentity`].
104    pub fn with_device(mut self, device: Arc<dyn DeviceIdentity>) -> Self {
105        self.device = device;
106        self
107    }
108
109    fn request_id() -> String {
110        use rand::Rng;
111        let n: u32 = rand::thread_rng().r#gen();
112        format!("{n:08x}")
113    }
114    fn headers(&self) -> Vec<(String, String)> {
115        let mut h = vec![
116            ("Content-Type".into(), "application/json".into()),
117            ("X-Keylight-Request-Id".into(), Self::request_id()),
118        ];
119        if !self.config.sdk_key.is_empty() {
120            h.push(("X-Keylight-SDK-Key".into(), self.config.sdk_key.clone()));
121        }
122        h
123    }
124    fn body_with_telemetry(&self, mut map: serde_json::Map<String, serde_json::Value>) -> String {
125        telemetry::apply(&mut map, self.config.app_version.as_deref());
126        serde_json::Value::Object(map).to_string()
127    }
128
129    /// True hardware id with a persisted cache: a fresh OS read wins (and refreshes the
130    /// cache); on a transient read failure the last successfully read id is reused so the
131    /// derived `machine_hash` stays stable across beacons. NO random fallback — if no id
132    /// has ever been read this returns `None` and callers omit the field.
133    fn cached_hardware_id(&self) -> Option<String> {
134        match self.device.hardware_id() {
135            Some(hw) => {
136                let _ = self.store.set_string(account::CACHED_HARDWARE_ID, &hw);
137                Some(hw)
138            }
139            None => self.store.get_string(account::CACHED_HARDWARE_ID),
140        }
141    }
142    /// Cross-SDK `machine_hash` (lowercase hex) from the cached hardware id, if any.
143    fn machine_hash(&self) -> Option<String> {
144        self.cached_hardware_id().map(|hw| {
145            crate::machine::machine_hash(&self.config.tenant_id, &self.config.product_id, &hw)
146        })
147    }
148
149    /// POST with retry/backoff. `decodable_4xx` lets a caller opt a 4xx body in (validate's 422).
150    fn post(&self, path: &str, body: &str, decodable_4xx: &[u16]) -> Result<(u16, String)> {
151        let url = self.api_url(path);
152        let headers = self.headers();
153        let mut attempt = 0u32;
154        loop {
155            attempt += 1;
156            match self.transport.post_json(&url, &headers, body) {
157                TransportOutcome::Response(r) => {
158                    if r.status == 200 || decodable_4xx.contains(&r.status) {
159                        return Ok((r.status, r.body));
160                    }
161                    match decide(r.status, attempt, r.retry_after) {
162                        RetryDecision::RetryAfter(ms) => {
163                            std::thread::sleep(std::time::Duration::from_millis(ms + jitter_ms()));
164                            continue;
165                        }
166                        RetryDecision::Stop => {
167                            if r.status == 429 {
168                                return Err(KeylightError::RateLimited {
169                                    retry_after: r.retry_after.unwrap_or(0),
170                                });
171                            }
172                            if (500..=599).contains(&r.status) || r.status == 408 {
173                                return Err(KeylightError::ServerError { status: r.status });
174                            }
175                            let msg = serde_json::from_str::<ErrorResp>(&r.body)
176                                .ok()
177                                .and_then(|e| e.error)
178                                .unwrap_or_default();
179                            return Err(KeylightError::ClientError {
180                                status: r.status,
181                                message: msg,
182                            });
183                        }
184                    }
185                }
186                TransportOutcome::Transient(_) if attempt < MAX_ATTEMPTS => {
187                    std::thread::sleep(std::time::Duration::from_millis(
188                        clamp_sleep_ms(backoff_ms(attempt)) + jitter_ms(),
189                    ));
190                    continue;
191                }
192                TransportOutcome::Transient(e) | TransportOutcome::Terminal(e) => {
193                    return Err(KeylightError::NetworkFailure(e));
194                }
195                TransportOutcome::Timeout => return Err(KeylightError::Timeout),
196            }
197        }
198    }
199
200    fn now() -> i64 {
201        std::time::SystemTime::now()
202            .duration_since(std::time::UNIX_EPOCH)
203            .map(|d| d.as_secs() as i64)
204            .unwrap_or(0)
205    }
206
207    fn api_url(&self, path: &str) -> String {
208        format!(
209            "{}/{}/{}/{}",
210            self.config.base_url, self.config.tenant_id, self.config.product_id, path
211        )
212    }
213
214    /// Verify a lease against the configured trusted keys at the current time.
215    fn verify(&self, lease: &Lease) -> crate::VerifyResult {
216        verify_lease(
217            lease,
218            &self.config.trusted_keys,
219            Self::now(),
220            crate::SKEW_SECONDS,
221        )
222    }
223
224    fn verify_or_reject(&self, lease: &Lease) -> Result<()> {
225        if self.verify(lease).is_trusted() {
226            Ok(())
227        } else {
228            Err(KeylightError::LeaseVerificationFailed)
229        }
230    }
231
232    pub fn activate(&self, key: &str) -> Result<ActivationResult> {
233        if !self.config.validate_key_format(key) {
234            return Ok(ActivationResult {
235                activated: false,
236                instance_id: None,
237                lease: None,
238                license_expires_at: None,
239                error: Some("Invalid license key format".into()),
240            });
241        }
242        let machine = machine_name();
243        let mut map = serde_json::Map::new();
244        map.insert("license_key".into(), key.into());
245        map.insert("instance_name".into(), machine.into());
246        if let Some(ft) = self.store.get_string(account::FREE_TIER_INSTANCE_ID) {
247            map.insert("free_tier_instance_id".into(), ft.into());
248        }
249        if let Some(hash) = self.machine_hash() {
250            map.insert("machine_hash".into(), hash.into());
251        }
252        let body = self.body_with_telemetry(map);
253
254        let (_, text) = match self.post("activate", &body, &[]) {
255            Ok(v) => v,
256            Err(KeylightError::ClientError { status, message }) => {
257                return Ok(ActivationResult {
258                    activated: false,
259                    instance_id: None,
260                    lease: None,
261                    license_expires_at: None,
262                    error: Some(if message.is_empty() {
263                        format!("Activation failed (HTTP {status})")
264                    } else {
265                        message
266                    }),
267                });
268            }
269            Err(e) => return Err(e),
270        };
271        let resp: ActivateResp =
272            serde_json::from_str(&text).map_err(|_| KeylightError::InvalidResponse)?;
273        if !resp.activated {
274            return Ok(ActivationResult {
275                activated: false,
276                instance_id: None,
277                lease: None,
278                license_expires_at: None,
279                error: resp.error.or(Some("Activation failed".into())),
280            });
281        }
282        if let Some(lease) = &resp.lease {
283            self.verify_or_reject(lease)?;
284        }
285
286        self.store.set_string(account::LICENSE_KEY, key)?;
287        if let Some(id) = &resp.instance_id {
288            self.store.set_string(account::INSTANCE_ID, id)?;
289        }
290        if let Some(lease) = &resp.lease {
291            self.store_lease(lease)?;
292        }
293        self.save_expiry(resp.license_expires_at)?;
294        self.touch_last_seen()?;
295        self.touch_validated_online()?;
296        Ok(ActivationResult {
297            activated: true,
298            instance_id: resp.instance_id,
299            lease: resp.lease,
300            license_expires_at: resp.license_expires_at,
301            error: None,
302        })
303    }
304
305    pub fn validate(&self) -> Result<ValidationResult> {
306        let key = self
307            .store
308            .get_string(account::LICENSE_KEY)
309            .ok_or(KeylightError::NoStoredLicense)?;
310        let instance = self
311            .store
312            .get_string(account::INSTANCE_ID)
313            .ok_or(KeylightError::NoStoredLicense)?;
314        let prev_state = self.state();
315        let prev_expiry = self.store.get_i64(account::LICENSE_EXPIRES_AT);
316        let mut map = serde_json::Map::new();
317        map.insert("license_key".into(), key.into());
318        map.insert("instance_id".into(), instance.into());
319        if let Some(hash) = self.machine_hash() {
320            map.insert("machine_hash".into(), hash.into());
321        }
322        let body = self.body_with_telemetry(map);
323
324        let (_status, text) = match self.post("validate", &body, &[422]) {
325            Ok(v) => v,
326            Err(KeylightError::ClientError { status, message }) => {
327                return Ok(ValidationResult {
328                    valid: false,
329                    lease: None,
330                    license_expires_at: None,
331                    error: Some(if message.is_empty() {
332                        format!("Validation failed (HTTP {status})")
333                    } else {
334                        message
335                    }),
336                });
337            }
338            Err(e) => return Err(e),
339        };
340        let resp: ValidateResp =
341            serde_json::from_str(&text).map_err(|_| KeylightError::InvalidResponse)?;
342        if let Some(lease) = &resp.lease {
343            self.verify_or_reject(lease)?;
344        }
345        if !resp.valid {
346            // Definitive rejection: persist whatever lease the server sent (e.g.
347            // "expired"/"fallback" so state() can resolve .limited/.expired), or
348            // clear the cached one when it sent none at all. The real worker's
349            // revoked/instance-not-active responses are `{"error": "..."}` with
350            // no `lease` field, so leaving the old (still "active") lease in
351            // place would let state() keep reporting Licensed off stale data.
352            match &resp.lease {
353                Some(lease) => self.store_lease(lease)?,
354                None => self.store.delete(account::LEASE)?,
355            }
356            self.save_expiry(resp.license_expires_at)?;
357            self.emit_lifecycle(&prev_state, prev_expiry);
358            return Ok(ValidationResult {
359                valid: false,
360                lease: resp.lease,
361                license_expires_at: resp.license_expires_at,
362                error: resp.error,
363            });
364        }
365        if let Some(lease) = &resp.lease {
366            self.store_lease(lease)?;
367        }
368        self.save_expiry(resp.license_expires_at)?;
369        self.touch_last_seen()?;
370        self.touch_validated_online()?;
371        self.emit_lifecycle(&prev_state, prev_expiry);
372        Ok(ValidationResult {
373            valid: true,
374            lease: resp.lease,
375            license_expires_at: resp.license_expires_at,
376            error: None,
377        })
378    }
379
380    pub fn deactivate(&self) -> Result<()> {
381        let key = self.store.get_string(account::LICENSE_KEY);
382        let instance = self.store.get_string(account::INSTANCE_ID);
383        let mut net_err = None;
384        if let (Some(k), Some(i)) = (key, instance) {
385            let mut map = serde_json::Map::new();
386            map.insert("license_key".into(), k.into());
387            map.insert("instance_id".into(), i.into());
388            let body = self.body_with_telemetry(map);
389            if let Err(e) = self.post("deactivate", &body, &[]) {
390                net_err = Some(e);
391            }
392        }
393        for a in [
394            account::LICENSE_KEY,
395            account::INSTANCE_ID,
396            account::LEASE,
397            account::LICENSE_EXPIRES_AT,
398            account::LAST_VALIDATED_ONLINE,
399            account::LAST_SEEN,
400        ] {
401            self.store.delete(a)?;
402        }
403        net_err.map_or(Ok(()), Err)
404    }
405
406    pub fn cached_lease(&self) -> Option<Lease> {
407        if let Some(max_days) = self.config.max_offline_days {
408            let last = self.store.get_i64(account::LAST_VALIDATED_ONLINE)?;
409            if Self::now() - last > (max_days as i64) * 86400 {
410                return None;
411            }
412        }
413        let lease: Lease = serde_json::from_str(&self.store.get_string(account::LEASE)?).ok()?;
414        let r = self.verify(&lease);
415        if r.is_trusted() && !r.expired && lease.status != "expired" {
416            Some(lease)
417        } else {
418            None
419        }
420    }
421
422    pub fn has_entitlement(&self, feature: &str) -> bool {
423        self.cached_lease()
424            .map(|l| l.entitlements.iter().any(|e| e == feature))
425            .unwrap_or(false)
426    }
427    pub fn has_stored_license(&self) -> bool {
428        self.store.get_string(account::LICENSE_KEY).is_some()
429    }
430    pub fn cached_license_key(&self) -> Option<String> {
431        self.store.get_string(account::LICENSE_KEY)
432    }
433    /// The cached license expiry (epoch seconds), if one was stored on the last
434    /// activate/validate. Parity with Swift `getCachedLicenseExpiresAt`.
435    pub fn cached_license_expires_at(&self) -> Option<i64> {
436        self.store.get_i64(account::LICENSE_EXPIRES_AT)
437    }
438
439    /// Persist a verified lease. Serializing a `Lease` (only owned strings, integers,
440    /// and a string vec) cannot fail, so a serialization error here would be a logic
441    /// bug rather than a recoverable condition.
442    fn store_lease(&self, lease: &Lease) -> Result<()> {
443        let json = serde_json::to_string(lease).expect("Lease serializes to JSON infallibly");
444        self.store.set_string(account::LEASE, &json)
445    }
446    fn save_expiry(&self, e: Option<i64>) -> Result<()> {
447        match e {
448            Some(v) => self
449                .store
450                .set_string(account::LICENSE_EXPIRES_AT, &v.to_string()),
451            None => self.store.delete(account::LICENSE_EXPIRES_AT),
452        }
453    }
454    fn touch_last_seen(&self) -> Result<()> {
455        self.store
456            .set_string(account::LAST_SEEN, &Self::now().to_string())
457    }
458    fn touch_validated_online(&self) -> Result<()> {
459        self.store
460            .set_string(account::LAST_VALIDATED_ONLINE, &Self::now().to_string())
461    }
462}
463
464impl Keylight {
465    pub fn start_trial(&self) -> Result<()> {
466        if self.store.get_string(account::TRIAL_START).is_none() {
467            self.store
468                .set_string(account::TRIAL_START, &Self::now().to_string())?;
469        }
470        if self
471            .store
472            .get_string(account::FREE_TIER_INSTANCE_ID)
473            .is_none()
474        {
475            self.store.set_string(
476                account::FREE_TIER_INSTANCE_ID,
477                &crate::store::device::uuid_v4_pub(),
478            )?;
479        }
480        Ok(())
481    }
482    pub fn check_trial(&self) -> TrialStatus {
483        let start = match self.store.get_i64(account::TRIAL_START) {
484            Some(v) => v,
485            None => return TrialStatus::NotStarted,
486        };
487        let days_elapsed = (Self::now() - start) / 86400;
488        let days_left = self.config.trial_duration_days as i64 - days_elapsed;
489        if days_left > 0 {
490            TrialStatus::Active { days_left }
491        } else {
492            TrialStatus::Expired
493        }
494    }
495    pub fn is_clock_manipulated(&self) -> bool {
496        let manipulated = self
497            .store
498            .get_i64(account::LAST_SEEN)
499            .is_some_and(|last| clock_manipulated(last, Self::now()));
500        if !manipulated {
501            let _ = self.touch_last_seen();
502        }
503        manipulated
504    }
505    pub fn free_tier_instance_id(&self) -> Result<String> {
506        if let Some(id) = self.store.get_string(account::FREE_TIER_INSTANCE_ID) {
507            return Ok(id);
508        }
509        let id = crate::store::device::uuid_v4_pub();
510        self.store.set_string(account::FREE_TIER_INSTANCE_ID, &id)?;
511        Ok(id)
512    }
513    /// Anonymous keyless beacon, debounced 24h or on state change. Errors swallowed.
514    pub fn report_keyless_state(&self, state: KeylessState) {
515        let last_state = self.store.get_string(account::KEYLESS_LAST_STATE);
516        let last_ping = self.store.get_i64(account::LAST_KEYLESS_PING_AT);
517        let changed = last_state.as_deref() != Some(state.wire());
518        let within = last_ping.map(|t| Self::now() - t < 86400).unwrap_or(false);
519        if !changed && within {
520            return;
521        }
522        let instance = match self.free_tier_instance_id() {
523            Ok(i) => i,
524            Err(_) => return,
525        };
526        let mut map = serde_json::Map::new();
527        map.insert("instance_id".into(), instance.into());
528        map.insert("state".into(), state.wire().into());
529        if let Some(hash) = self.machine_hash() {
530            map.insert("machine_hash".into(), hash.into());
531        }
532        let body = self.body_with_telemetry(map);
533        // Route through the shared retry/backoff loop; with no decodable 4xx an
534        // `Ok` here is exactly an HTTP 200, so the debounce state is persisted
535        // only on success. Errors are swallowed (anonymous best-effort beacon).
536        if self.post("keyless", &body, &[]).is_ok() {
537            let _ = self
538                .store
539                .set_string(account::KEYLESS_LAST_STATE, state.wire());
540            let _ = self
541                .store
542                .set_string(account::LAST_KEYLESS_PING_AT, &Self::now().to_string());
543        }
544    }
545    /// Resolve the current high-level state from cached data (no network).
546    pub fn state(&self) -> LicenseState {
547        // Backward clock-rollback guard: if the system clock has jumped back more
548        // than the tolerance since our last recorded contact, refuse to resolve a
549        // usable state — this is the offline vector for reviving an expired lease.
550        // Read-only (does not touch `last_seen`); the forward-jump component lives
551        // in `is_clock_manipulated()`. Self-heals on the next successful
552        // `validate()`, which re-anchors `last_seen`.
553        if self
554            .store
555            .get_i64(account::LAST_SEEN)
556            .is_some_and(|last| clock_rolled_back(last, Self::now()))
557        {
558            return LicenseState::Invalid;
559        }
560        // Offline bound: a validated license must not run forever without a
561        // successful server re-check. When `max_offline_days` is configured the
562        // cached lease is only usable if we have a `last_validated_online` anchor
563        // within the cap. Both a *stale* anchor (older than the cap) and a
564        // *missing* anchor are fail-closed — the latter matters because an
565        // attacker who deletes the anchor to reset the offline clock must not
566        // thereby revive the lease. This mirrors `cached_lease()` (whose `?` on
567        // `get_i64` already short-circuits a missing anchor) and Swift's
568        // `isWithinOfflineGrace`. When `max_offline_days` is `None` the cap is
569        // disabled entirely (unlimited offline). Dropping the lease here lets a
570        // stored license fall through to `Expired` via the `had_stored_license`
571        // path in `resolve_state`, while trials / free-tier (no lease, no license)
572        // are unaffected.
573        let offline_bound_ok = match self.config.max_offline_days {
574            Some(max_days) => self
575                .store
576                .get_i64(account::LAST_VALIDATED_ONLINE)
577                .is_some_and(|last| Self::now() - last <= (max_days as i64) * 86400),
578            None => true,
579        };
580        let lease = self
581            .store
582            .get_string(account::LEASE)
583            .and_then(|s| serde_json::from_str::<Lease>(&s).ok());
584        let (status, current) = match &lease {
585            Some(l) if offline_bound_ok => {
586                let r = self.verify(l);
587                (r.is_trusted().then(|| l.status.clone()), !r.expired)
588            }
589            _ => (None, false),
590        };
591        resolve_state(
592            status.as_deref(),
593            current,
594            self.has_stored_license(),
595            &self.check_trial(),
596            self.config.free_tier_enabled,
597        )
598    }
599}
600
601impl Keylight {
602    /// Validate now only if enough time has passed (debounce 5min, stale 6h, or near expiry).
603    pub fn refresh_if_needed(&self) -> Result<Option<ValidationResult>> {
604        if !self.has_stored_license() {
605            return Ok(None);
606        }
607        if let Some(last) = self.store.get_i64(account::LAST_VALIDATED_ONLINE) {
608            let now = Self::now();
609            if now - last < REFRESH_DEBOUNCE {
610                return Ok(None);
611            }
612            let near_expiry = self
613                .store
614                .get_i64(account::LICENSE_EXPIRES_AT)
615                .is_some_and(|exp| exp - now < 86400);
616            if now - last < REFRESH_STALE && !near_expiry {
617                return Ok(None);
618            }
619        }
620        Ok(Some(self.validate()?))
621    }
622    /// Called on app launch: if a license is stored, **always** validate against
623    /// the server (no staleness gate — unlike [`Self::refresh_if_needed`]), so a
624    /// dashboard revoke or genuine expiry takes effect on the very next launch
625    /// rather than lagging behind the in-session refresh cadence. `validate()`
626    /// does not mutate state on a transient/network error, so a launch with no
627    /// connectivity keeps running on the existing cached lease (last-known-good),
628    /// subject to the offline bound enforced by [`Self::state`].
629    pub fn check_on_launch(&self) -> Result<()> {
630        if self.has_stored_license() {
631            let _ = self.validate()?;
632        }
633        Ok(())
634    }
635    /// Force a re-validation on **active use** — app foreground, window focus,
636    /// popover open — debounced to 60s in memory (parity with Swift
637    /// `activeRevalidate()`).
638    ///
639    /// Unlike [`Self::refresh_if_needed`] this bypasses the debounce/stale/
640    /// near-expiry gates entirely, so a dashboard revoke lands within minutes of
641    /// the user next touching the app instead of waiting for the lease to go
642    /// stale (up to 6h) or for a relaunch (up to the full lease lifetime).
643    ///
644    /// Behavior:
645    /// - **No stored license key** — no-op, no network call, returns `None`.
646    /// - **Inside the 60s window** — suppressed, returns `None`. The window is
647    ///   in-memory only: it does not survive a process restart, and it is
648    ///   consumed by the *attempt*, so a failed call also holds it (matching
649    ///   Swift, and keeping a hammered foreground hook off the network).
650    /// - **Definitive rejection** (`valid:false`, e.g. the revoke path's HTTP
651    ///   422) — downgrades immediately through the same [`Self::validate`]
652    ///   rejection branch, and returns `Some(result)` with `valid == false`.
653    /// - **Transient failure** (offline, timeout, 5xx, rate limit) — returns
654    ///   `None` with state untouched. This is the safety property: a network
655    ///   blip must never downgrade a live session. `validate()` mutates nothing
656    ///   on the error path, so the cached lease survives intact and access stays
657    ///   governed by the offline bound in [`Self::state`].
658    ///
659    /// The error is intentionally swallowed rather than returned: this is a
660    /// fire-and-forget UI hook whose whole contract is "never break the running
661    /// session", so there is no failure a caller could act on differently. Use
662    /// [`Self::validate`] directly when you need the error.
663    pub fn active_revalidate(&self) -> Option<ValidationResult> {
664        if !self.has_stored_license() {
665            return None;
666        }
667        {
668            // A panic while holding this lock is not reachable (the guarded
669            // section is two moves), so recover from poisoning rather than
670            // letting an unrelated panic elsewhere disable revalidation.
671            let mut last = self
672                .last_active_revalidate_at
673                .lock()
674                .unwrap_or_else(|e| e.into_inner());
675            if last.is_some_and(|t| t.elapsed() < ACTIVE_REVALIDATE_DEBOUNCE) {
676                return None;
677            }
678            *last = Some(Instant::now());
679        }
680        self.validate().ok()
681    }
682
683    /// Hosted upgrade URL pre-filled with the cached key (parity with Swift upgradeURL).
684    pub fn upgrade_url(&self) -> Option<String> {
685        let key = self.cached_license_key()?;
686        Some(format!(
687            "https://portal.keylight.dev/p/{}/upgrade/{}?key={}",
688            self.config.tenant_id,
689            self.config.product_id,
690            urlencode(&key)
691        ))
692    }
693
694    /// Compute the post-validation state and fire a lifecycle event if the resolved
695    /// state crossed a transition. The previous state is re-derived from the persisted
696    /// lease on each call (so transitions don't re-fire across restarts). Errors swallowed.
697    fn emit_lifecycle(&self, prev_state: &LicenseState, prev_expiry: Option<i64>) {
698        let next_state = self.state();
699        // Option<i64> ordering: None < Some(_), so this is true exactly when a new
700        // expiry exists and is later than the previous one (or there was none).
701        let expiry_moved_later = self.store.get_i64(account::LICENSE_EXPIRES_AT) > prev_expiry;
702        if let Some(ev) = crate::state::lifecycle_event(prev_state, &next_state, expiry_moved_later)
703        {
704            if let Some(h) = &self.on_event {
705                h(ev);
706            }
707        }
708    }
709}
710
711const REFRESH_DEBOUNCE: i64 = 300; // 5 min
712const REFRESH_STALE: i64 = 21600; // 6 h
713/// In-memory floor between two `active_revalidate()` network calls (Swift parity).
714const ACTIVE_REVALIDATE_DEBOUNCE: Duration = Duration::from_secs(60);
715
716fn urlencode(s: &str) -> String {
717    use std::fmt::Write;
718    let mut out = String::with_capacity(s.len());
719    for b in s.bytes() {
720        match b {
721            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
722                out.push(b as char)
723            }
724            _ => {
725                let _ = write!(out, "%{b:02X}");
726            }
727        }
728    }
729    out
730}
731
732/// Best-effort human-readable machine name for the activation's `instance_name`
733/// (display only — the seat identity is the server-issued `instance_id`). Falls back
734/// through common env vars and the `hostname` command before a generic default.
735fn machine_name() -> String {
736    for var in ["HOSTNAME", "COMPUTERNAME", "HOST"] {
737        if let Ok(v) = std::env::var(var) {
738            let v = v.trim().to_string();
739            if !v.is_empty() {
740                return v;
741            }
742        }
743    }
744    if let Ok(out) = std::process::Command::new("hostname").output() {
745        let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
746        if !v.is_empty() {
747            return v;
748        }
749    }
750    "device".to_string()
751}
752
753/// Small random backoff jitter (0..250ms) to avoid synchronized retries
754/// (the retry policy in `http::retry` stays pure; jitter is applied here).
755fn jitter_ms() -> u64 {
756    use rand::Rng;
757    rand::thread_rng().gen_range(0..250)
758}