Add license handling for expired licenses

This commit is contained in:
Gregory Schier
2025-12-09 13:51:02 -08:00
parent 1d257b365b
commit d35116c494
8 changed files with 290 additions and 115 deletions

View File

@@ -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();

View File

@@ -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, } };

View File

@@ -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<Utc>,
},
Trialing {
end: DateTime<Utc>,
},
Error {
message: String,
code: String,
},
// Server Types
Active {
#[serde(rename = "periodEnd")]
period_end: DateTime<Utc>,
#[serde(rename = "cancelAt")]
cancel_at: Option<DateTime<Utc>>,
},
Inactive {
status: String,
},
Expired {
changes: i32,
#[serde(rename = "changesUrl")]
changes_url: Option<String>,
#[serde(rename = "billingUrl")]
billing_url: String,
#[serde(rename = "periodEnd")]
period_end: DateTime<Utc>,
},
PastDue {
#[serde(rename = "billingUrl")]
billing_url: String,
#[serde(rename = "periodEnd")]
period_end: DateTime<Utc>,
},
}
pub async fn activate_license<R: Runtime>(
window: &WebviewWindow<R>,
license_key: &str,
@@ -141,16 +177,6 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> 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<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {
let payload = CheckActivationRequestPayload {
app_platform: get_os_str().to_string(),
@@ -159,10 +185,10 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
let activation_id = get_activation_id(window.app_handle()).await;
let settings = window.db().get_settings();
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS));
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS)).and_utc();
let has_activation_id = !activation_id.is_empty();
let trial_period_active = Utc::now().naive_utc() < trial_end;
let trial_period_active = Utc::now() < trial_end;
match (has_activation_id, trial_period_active) {
(false, true) => Ok(LicenseCheckStatus::Trialing { end: trial_end }),
@@ -173,7 +199,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
info!("Checking license activation");
// A license has been activated, so let's check the license server
let client = yaak_api_client(window.app_handle())?;
let path = format!("/licenses/activations/{activation_id}/check");
let path = format!("/licenses/activations/{activation_id}/check-v2");
let response = client.post(build_url(&path)).json(&payload).send().await?;
if response.status().is_client_error() {
@@ -189,13 +215,14 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
return Err(ServerError);
}
let body: CheckActivationResponsePayload = response.json().await?;
if !body.active {
info!("Inactive License {:?}", body);
return Ok(LicenseCheckStatus::InvalidLicense);
let body_text = response.text().await?;
match serde_json::from_str::<LicenseCheckStatus>(&body_text) {
Ok(b) => Ok(b),
Err(e) => {
warn!("Failed to decode server response: {} {:?}", body_text, e);
Err(JsonError(e))
}
}
Ok(LicenseCheckStatus::CommercialUse)
}
}
}

View File

@@ -622,6 +622,14 @@ impl PluginManager {
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> Result<GetHttpAuthenticationConfigResponse> {
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()

View File

@@ -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<string | null>('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: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
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: <div className="min-w-[12rem]">Renew License</div>,
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
onSelect: () => openUrl(data.data.billingUrl),
},
{
label: 'Enter License Key',
leftSlot: <Icon icon="key_round" />,
hidden: data.data.changesUrl == null,
onSelect: openLicenseDialog,
},
{ type: 'separator' },
{
label: <span className="text-text-subtle">Remind me Later</span>,
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
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 (
<Dropdown items={detail.options}>
<PillButton color={detail.color}>
<div className="flex items-center gap-0.5">
{detail.label} <Icon icon="chevron_down" className="opacity-60" />
</div>
</PillButton>
</Dropdown>
);
}
return (
<PillButton color={detail.color} onClick={() => openSettings.mutate('license')}>
<PillButton color={detail.color} onClick={openLicenseDialog}>
{detail.label}
</PillButton>
);
}
function openLicenseDialog() {
openSettings.mutate('license');
}

View File

@@ -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 <Banner color="success">Your license is active 🥳</Banner>;
case 'trialing':
return (
<Banner color="info" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
<strong>
{pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))}
</strong>{' '}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
);
case 'personal_use':
return (
<Banner color="notice" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use free, forever.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
);
case 'inactive':
return (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{' '}
for more details
</Banner>
);
case 'expired':
return (
<Banner color="notice">
Your license expired{' '}
<strong>{formatDate(check.data.data.periodEnd, 'MMMM dd, yyyy')}</strong>. Please{' '}
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
updates.
{check.data.data.changesUrl && (
<>
<br />
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
</>
)}
</Banner>
);
case 'past_due':
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please{' '}
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
</Banner>
);
case 'error':
return (
<Banner color="danger">
License check failed: {check.data.data.message} (Code: {check.data.data.code})
</Banner>
);
}
};
return (
<div className="flex flex-col gap-6 max-w-xl">
{check.data?.type === 'commercial_use' ? (
<Banner color="success">Your license is active 🥳</Banner>
) : check.data?.type === 'trialing' ? (
<Banner color="info" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
<strong>{pluralizeCount('day', differenceInDays(check.data.end, new Date()))}</strong>{' '}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data?.type ?? ''}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
) : check.data?.type === 'personal_use' ? (
<Banner color="notice" className="@container flex items-center gap-x-5 max-w-xl">
<LocalImage src="static/greg.jpeg" className="hidden @sm:block rounded-full h-14 w-14" />
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use free, forever.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href="mailto:support@yaak.app">
Contact Support
</Link>
<Icon icon="dot" size="sm" color="secondary" />
<Link
noUnderline
href={`https://yaak.app/pricing?s=learn&t=${check.data?.type ?? ''}`}
>
Learn More
</Link>
</div>
</p>
</Banner>
) : null}
{renderBanner()}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.type === 'invalid_license' && (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link> for
more details
</Banner>
)}
{check.data?.type === 'commercial_use' ? (
{check.data?.status === 'active' ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
Deactivate License
@@ -120,7 +170,7 @@ function SettingsLicenseCmp() {
rightSlot={<Icon icon="external_link" />}
onClick={() =>
openUrl(
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.type ?? ''}`,
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ''}`,
)
}
>

View File

@@ -28,7 +28,10 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const showEncryptionSetup =
workspace?.encryptionKeyChallenge != null && workspaceMeta?.encryptionKey == null;
workspace != null &&
workspaceMeta != null &&
workspace.encryptionKeyChallenge != null &&
workspaceMeta.encryptionKey == null;
return (
<div

View File

@@ -1,6 +1,7 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import {
AlarmClockIcon,
AlertTriangleIcon,
ArchiveIcon,
ArrowBigDownDashIcon,
@@ -67,6 +68,7 @@ import {
FolderSymlinkIcon,
FolderSyncIcon,
FolderUpIcon,
GiftIcon,
GitBranchIcon,
GitBranchPlusIcon,
GitCommitIcon,
@@ -129,6 +131,7 @@ import type { CSSProperties, HTMLAttributes } from 'react';
import { memo } from 'react';
const icons = {
alarm_clock: AlarmClockIcon,
alert_triangle: AlertTriangleIcon,
archive: ArchiveIcon,
arrow_big_down_dash: ArrowBigDownDashIcon,
@@ -194,6 +197,7 @@ const icons = {
folder_symlink: FolderSymlinkIcon,
folder_sync: FolderSyncIcon,
folder_up: FolderUpIcon,
gift: GiftIcon,
git_branch: GitBranchIcon,
git_branch_plus: GitBranchPlusIcon,
git_commit: GitCommitIcon,