From d35116c494c69ee9c9d542aec8389b424e943ebc Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 9 Dec 2025 13:51:02 -0800 Subject: [PATCH] Add license handling for expired licenses --- src-tauri/src/notifications.rs | 15 +- src-tauri/yaak-license/bindings/license.ts | 4 +- src-tauri/yaak-license/src/license.rs | 83 ++++++--- src-tauri/yaak-plugins/src/manager.rs | 8 + src-web/components/LicenseBadge.tsx | 116 ++++++++++-- .../components/Settings/SettingsLicense.tsx | 170 +++++++++++------- src-web/components/WorkspaceHeader.tsx | 5 +- src-web/components/core/Icon.tsx | 4 + 8 files changed, 290 insertions(+), 115 deletions(-) diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index de532844..ca755965 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -85,13 +85,18 @@ impl YaakNotifier { let license_check = { use yaak_license::{LicenseCheckStatus, check_license}; match check_license(window).await { - Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(), - Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(), - Ok(LicenseCheckStatus::InvalidLicense) => "invalid_license".to_string(), - Ok(LicenseCheckStatus::Trialing { .. }) => "trialing".to_string(), - Err(_) => "unknown".to_string(), + Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal", + Ok(LicenseCheckStatus::Active { .. }) => "commercial", + Ok(LicenseCheckStatus::PastDue { .. }) => "past_due", + Ok(LicenseCheckStatus::Inactive { .. }) => "invalid_license", + Ok(LicenseCheckStatus::Trialing { .. }) => "trialing", + Ok(LicenseCheckStatus::Expired { .. }) => "expired", + Ok(LicenseCheckStatus::Error { .. }) => "error", + Err(_) => "unknown", } + .to_string() }; + #[cfg(not(feature = "license"))] let license_check = "disabled".to_string(); diff --git a/src-tauri/yaak-license/bindings/license.ts b/src-tauri/yaak-license/bindings/license.ts index 3d4fd90d..52132530 100644 --- a/src-tauri/yaak-license/bindings/license.ts +++ b/src-tauri/yaak-license/bindings/license.ts @@ -6,8 +6,6 @@ export type ActivateLicenseRequestPayload = { licenseKey: string, appVersion: st export type ActivateLicenseResponsePayload = { activationId: string, }; -export type CheckActivationResponsePayload = { active: boolean, }; - export type DeactivateLicenseRequestPayload = { appVersion: string, appPlatform: string, }; -export type LicenseCheckStatus = { "type": "personal_use", trial_ended: string, } | { "type": "commercial_use" } | { "type": "invalid_license" } | { "type": "trialing", end: string, }; +export type LicenseCheckStatus = { "status": "personal_use", "data": { trial_ended: string, } } | { "status": "trialing", "data": { end: string, } } | { "status": "error", "data": { message: string, code: string, } } | { "status": "active", "data": { periodEnd: string, cancelAt: string | null, } } | { "status": "inactive", "data": { status: string, } } | { "status": "expired", "data": { changes: number, changesUrl: string | null, billingUrl: string, periodEnd: string, } } | { "status": "past_due", "data": { billingUrl: string, periodEnd: string, } }; diff --git a/src-tauri/yaak-license/src/license.rs b/src-tauri/yaak-license/src/license.rs index 8b5943a4..9928baf2 100644 --- a/src-tauri/yaak-license/src/license.rs +++ b/src-tauri/yaak-license/src/license.rs @@ -1,6 +1,6 @@ -use crate::error::Error::{ClientError, ServerError}; +use crate::error::Error::{ClientError, JsonError, ServerError}; use crate::error::Result; -use chrono::{NaiveDateTime, Utc}; +use chrono::{DateTime, Utc}; use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::ops::Add; @@ -24,13 +24,6 @@ pub struct CheckActivationRequestPayload { pub app_platform: String, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "license.ts")] -pub struct CheckActivationResponsePayload { - pub active: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "license.ts")] @@ -63,6 +56,49 @@ pub struct APIErrorResponsePayload { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status", content = "data")] +#[ts(export, export_to = "license.ts")] +pub enum LicenseCheckStatus { + // Local Types + PersonalUse { + trial_ended: DateTime, + }, + Trialing { + end: DateTime, + }, + Error { + message: String, + code: String, + }, + + // Server Types + Active { + #[serde(rename = "periodEnd")] + period_end: DateTime, + #[serde(rename = "cancelAt")] + cancel_at: Option>, + }, + Inactive { + status: String, + }, + Expired { + changes: i32, + #[serde(rename = "changesUrl")] + changes_url: Option, + #[serde(rename = "billingUrl")] + billing_url: String, + #[serde(rename = "periodEnd")] + period_end: DateTime, + }, + PastDue { + #[serde(rename = "billingUrl")] + billing_url: String, + #[serde(rename = "periodEnd")] + period_end: DateTime, + }, +} + pub async fn activate_license( window: &WebviewWindow, license_key: &str, @@ -141,16 +177,6 @@ pub async fn deactivate_license(window: &WebviewWindow) -> Result Ok(()) } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case", tag = "type")] -#[ts(export, export_to = "license.ts")] -pub enum LicenseCheckStatus { - PersonalUse { trial_ended: NaiveDateTime }, - CommercialUse, - InvalidLicense, - Trialing { end: NaiveDateTime }, -} - pub async fn check_license(window: &WebviewWindow) -> Result { let payload = CheckActivationRequestPayload { app_platform: get_os_str().to_string(), @@ -159,10 +185,10 @@ pub async fn check_license(window: &WebviewWindow) -> Result Ok(LicenseCheckStatus::Trialing { end: trial_end }), @@ -173,7 +199,7 @@ pub async fn check_license(window: &WebviewWindow) -> Result(window: &WebviewWindow) -> Result(&body_text) { + Ok(b) => Ok(b), + Err(e) => { + warn!("Failed to decode server response: {} {:?}", body_text, e); + Err(JsonError(e)) + } } - - Ok(LicenseCheckStatus::CommercialUse) } } } diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index 9cee0b52..5537bcf2 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -622,6 +622,14 @@ impl PluginManager { values: HashMap, model_id: &str, ) -> Result { + if auth_name == "none" { + return Ok(GetHttpAuthenticationConfigResponse { + args: Vec::new(), + plugin_ref_id: "auth-none".to_string(), + actions: None, + }); + } + let results = self.get_http_authentication_summaries(window).await?; let plugin = results .iter() diff --git a/src-web/components/LicenseBadge.tsx b/src-web/components/LicenseBadge.tsx index e687ef9a..78cfe33e 100644 --- a/src-web/components/LicenseBadge.tsx +++ b/src-web/components/LicenseBadge.tsx @@ -1,22 +1,86 @@ +import { openUrl } from '@tauri-apps/plugin-opener'; import type { LicenseCheckStatus } from '@yaakapp-internal/license'; import { useLicense } from '@yaakapp-internal/license'; import { settingsAtom } from '@yaakapp-internal/models'; +import { differenceInCalendarDays } from 'date-fns'; +import { formatDate } from 'date-fns/format'; import { useAtomValue } from 'jotai'; import type { ReactNode } from 'react'; import { openSettings } from '../commands/openSettings'; +import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; +import { jotaiStore } from '../lib/jotai'; import { CargoFeature } from './CargoFeature'; import type { ButtonProps } from './core/Button'; +import { Dropdown, type DropdownItem } from './core/Dropdown'; +import { Icon } from './core/Icon'; import { PillButton } from './core/PillButton'; -const details: Record< - LicenseCheckStatus['type'], - { label: ReactNode; color: ButtonProps['color'] } | null -> = { - commercial_use: null, - invalid_license: { label: 'License Error', color: 'danger' }, - personal_use: { label: 'Personal Use', color: 'notice' }, - trialing: { label: 'Commercial Trial', color: 'secondary' }, -}; +const dismissedAtom = atomWithKVStorage('dismissed_license_expired', null); + +function getDetail( + data: LicenseCheckStatus, + dismissedExpired: string | null, +): { label: ReactNode; color: ButtonProps['color']; options?: DropdownItem[] } | null | undefined { + const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null; + + switch (data.status) { + case 'active': + return null; + case 'personal_use': + return { label: 'Personal Use', color: 'notice' }; + case 'trialing': + return { label: 'Commercial Trial', color: 'secondary' }; + case 'error': + return { label: 'Error', color: 'danger' }; + case 'inactive': + return { label: 'Personal Use', color: 'notice' }; + case 'past_due': + return { label: 'Past Due', color: 'danger' }; + case 'expired': + // Don't show the expired message if it's been less than 14 days since the last dismissal + if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) { + return null; + } + + return { + color: 'notice', + label: data.data.changes > 0 ? 'Updates Paused' : 'License Expired', + options: [ + { + label: `${data.data.changes} New Updates`, + color: 'success', + leftSlot: , + rightSlot: , + hidden: data.data.changes === 0 || data.data.changesUrl == null, + onSelect: () => openUrl(data.data.changesUrl ?? ''), + }, + { + type: 'separator', + label: `License expired ${formatDate(data.data.periodEnd, 'MMM dd, yyyy')}`, + }, + { + label:
Renew License
, + leftSlot: , + rightSlot: , + hidden: data.data.changesUrl == null, + onSelect: () => openUrl(data.data.billingUrl), + }, + { + label: 'Enter License Key', + leftSlot: , + hidden: data.data.changesUrl == null, + onSelect: openLicenseDialog, + }, + { type: 'separator' }, + { + label: Remind me Later, + leftSlot: , + onSelect: () => jotaiStore.set(dismissedAtom, new Date().toISOString()), + }, + ], + }; + } +} export function LicenseBadge() { return ( @@ -29,10 +93,15 @@ export function LicenseBadge() { function LicenseBadgeCmp() { const { check } = useLicense(); const settings = useAtomValue(settingsAtom); + const dismissed = useAtomValue(dismissedAtom); + + // Dismissed license badge + if (settings.hideLicenseBadge) { + return null; + } if (check.error) { - // Failed to check for license. Probably a network or server error so just don't - // show anything. + // Failed to check for license. Probably a network or server error, so just don't show anything. return null; } @@ -41,19 +110,30 @@ function LicenseBadgeCmp() { return null; } - // Dismissed license badge - if (settings.hideLicenseBadge) { - return null; - } - - const detail = details[check.data.type]; + const detail = getDetail(check.data, dismissed); if (detail == null) { return null; } + if (detail.options && detail.options.length > 0) { + return ( + + +
+ {detail.label} +
+
+
+ ); + } + return ( - openSettings.mutate('license')}> + {detail.label} ); } + +function openLicenseDialog() { + openSettings.mutate('license'); +} diff --git a/src-web/components/Settings/SettingsLicense.tsx b/src-web/components/Settings/SettingsLicense.tsx index fc198d01..ea0134e1 100644 --- a/src-web/components/Settings/SettingsLicense.tsx +++ b/src-web/components/Settings/SettingsLicense.tsx @@ -1,6 +1,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { useLicense } from '@yaakapp-internal/license'; import { differenceInDays } from 'date-fns'; +import { formatDate } from 'date-fns/format'; import { useState } from 'react'; import { useToggle } from '../../hooks/useToggle'; import { pluralizeCount } from '../../lib/pluralize'; @@ -31,71 +32,120 @@ function SettingsLicenseCmp() { return null; } + const renderBanner = () => { + if (!check.data) return null; + + switch (check.data.status) { + case 'active': + return Your license is active 🥳; + + case 'trialing': + return ( + + +

+ + {pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))} + {' '} + left to evaluate Yaak for commercial use. +
+ Personal use is always free, forever. + +

+ + Contact Support + + + + Learn More + +
+

+
+ ); + + case 'personal_use': + return ( + + +

+ Your commercial-use trial has ended. +
+ + You may continue using Yaak for personal use free, forever. +
A license is required for commercial use. +
+ +

+ + Contact Support + + + + Learn More + +
+

+
+ ); + + case 'inactive': + return ( + + Your license is invalid. Please Sign In{' '} + for more details + + ); + + case 'expired': + return ( + + Your license expired{' '} + {formatDate(check.data.data.periodEnd, 'MMMM dd, yyyy')}. Please{' '} + Resubscribe to continue receiving + updates. + {check.data.data.changesUrl && ( + <> +
+ What's new in latest builds + + )} +
+ ); + + case 'past_due': + return ( + + Your payment method needs attention. +
+ To re-activate your license, please{' '} + update your billing info. +
+ ); + + case 'error': + return ( + + License check failed: {check.data.data.message} (Code: {check.data.data.code}) + + ); + } + }; + return (
- {check.data?.type === 'commercial_use' ? ( - Your license is active 🥳 - ) : check.data?.type === 'trialing' ? ( - - -

- {pluralizeCount('day', differenceInDays(check.data.end, new Date()))}{' '} - left to evaluate Yaak for commercial use. -
- Personal use is always free, forever. - -

- - Contact Support - - - - Learn More - -
-

-
- ) : check.data?.type === 'personal_use' ? ( - - -

- Your commercial-use trial has ended. -
- - You may continue using Yaak for personal use free, forever. -
A license is required for commercial use. -
- -

- - Contact Support - - - - Learn More - -
-

-
- ) : null} + {renderBanner()} {check.error && {check.error}} {activate.error && {activate.error}} - {check.data?.type === 'invalid_license' && ( - - Your license is invalid. Please Sign In for - more details - - )} - - {check.data?.type === 'commercial_use' ? ( + {check.data?.status === 'active' ? (