Better license flows

This commit is contained in:
Gregory Schier
2025-02-24 05:59:15 -08:00
parent 2b1431d041
commit af7782c93b
9 changed files with 184 additions and 70 deletions

94
src-tauri/Cargo.lock generated
View File

@@ -514,6 +514,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7"
dependencies = [
"objc-sys",
]
[[package]]
name = "block2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58aa60e59d8dbfcc36138f5f18be5f24394d33b38b24f7fd0b1caa33095f22f"
dependencies = [
"block-sys",
"objc2",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -2420,6 +2439,16 @@ dependencies = [
"png",
]
[[package]]
name = "icrate"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb69199826926eb864697bddd27f73d9fddcffc004f5733131e15b465e30642"
dependencies = [
"block2 0.4.0",
"objc2",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -3292,7 +3321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"libc",
"objc2",
"objc2-core-data",
@@ -3308,7 +3337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-core-location",
"objc2-foundation",
@@ -3320,7 +3349,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
@@ -3332,7 +3361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
@@ -3343,7 +3372,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
"objc2-metal",
@@ -3355,7 +3384,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-contacts",
"objc2-foundation",
@@ -3374,7 +3403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2",
@@ -3386,7 +3415,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -3399,11 +3428,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6788b04a18ea31e3dc3ab256b8546639e5bbae07c1a0dc4ea8615252bc6aee9a"
dependencies = [
"bitflags 2.6.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -3411,7 +3452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
"objc2-metal",
@@ -3434,7 +3475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -3454,7 +3495,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2",
"objc2-foundation",
]
@@ -3466,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-core-location",
"objc2-foundation",
@@ -3479,7 +3520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65"
dependencies = [
"bitflags 2.6.0",
"block2",
"block2 0.5.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
@@ -3611,6 +3652,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "osakit"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35366a452fce3f8947eb2f33226a133aaf0cacedef2af67ade348d58be7f85d0"
dependencies = [
"icrate",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 1.0.63",
]
[[package]]
name = "pad"
version = "0.1.6"
@@ -4513,7 +4568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8af382a047821a08aa6bfc09ab0d80ff48d45d8726f7cd8e44891f7cb4a4278e"
dependencies = [
"ashpd",
"block2",
"block2 0.5.1",
"glib-sys",
"gobject-sys",
"gtk-sys",
@@ -5980,17 +6035,18 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad3de2b9203bb00b9765e637a9878aaace34df40ae484878b8cea7a5bd5f9188"
checksum = "ebf3da08c36fb03c98c76e5563d4e74d9a590df0f40978cbe07f39cb52833f7c"
dependencies = [
"base64 0.22.1",
"dirs 5.0.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
@@ -7544,7 +7600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e644bf458e27b11b0ecafc9e5633d1304fdae82baca1d42185669752fe6ca4f"
dependencies = [
"base64 0.22.1",
"block2",
"block2 0.5.1",
"cookie",
"crossbeam-channel",
"dpi",

View File

@@ -67,7 +67,7 @@ tauri-plugin-opener = "2.2.5"
tauri-plugin-os = "2.2.0"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = "2.2.1"
tauri-plugin-updater = "2.4.0"
tauri-plugin-updater = "2.5.0"
tauri-plugin-window-state = "2.2.1"
tokio = { version = "1.43.0", features = ["sync"] }
tokio-stream = "0.1.17"

View File

@@ -336,12 +336,14 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
label: banner,
},
{
color: 'success',
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect() {
openWorkspaceSettings.mutate({ openSyncMenu: true });
},
},
{ type: 'separator' },
{
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
@@ -396,8 +398,8 @@ function SetupGitDropdown({
leftSlot: <Icon icon="magic_wand" />,
onSelect: initRepo,
},
{ type: 'separator' },
{
color: 'warning',
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {

View File

@@ -1,14 +1,14 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../hooks/useAppInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { openSettings } from '../commands/openSettings';
import {SettingsTab} from "./Settings/SettingsTab";
import { SettingsTab } from './Settings/SettingsTab';
const details: Record<
LicenseCheckStatus['type'] | 'dev' | 'beta',
@@ -26,22 +26,30 @@ const details: Record<
dev: { label: 'Develop', color: 'secondary' },
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'primary' },
trialing: { label: 'Personal Use', color: 'primary' },
personal_use: { label: 'Personal Use', color: 'success' },
trialing: { label: 'Active Trial', color: 'success' },
};
export function LicenseBadge() {
const { check } = useLicense();
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
if (check.data == null) {
// Hasn't loaded yet
if (licenseDetails == null || check.data == null) {
return null;
}
const checkType = appInfo.version.includes('beta')
? 'beta'
: appInfo.isDev
? 'dev'
: check.data.type;
// User has confirmed they are using Yaak for personal use only, so hide badge
if (licenseDetails.confirmedPersonalUse) {
return null;
}
// User is trialing but has already seen the message, so hide badge
if (check.data.type === 'trialing' && licenseDetails.hasDismissedTrial) {
return null;
}
const checkType = appInfo.version.includes('beta') ? 'beta' : check.data.type;
const detail = details[checkType];
if (detail == null) {
return null;
@@ -53,11 +61,13 @@ export function LicenseBadge() {
variant="border"
className="!rounded-full mx-1"
onClick={async () => {
if (checkType === 'beta') {
await openUrl('https://feedback.yaak.app');
} else {
openSettings.mutate(SettingsTab.License);
if (check.data.type === 'trialing') {
await setLicenseDetails((v) => ({
...v,
dismissedTrial: true,
}));
}
openSettings.mutate(SettingsTab.License);
}}
color={detail.color}
event={{ id: 'license-badge', status: check.data.type }}

View File

@@ -1,47 +1,75 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { formatDistanceToNowStrict } from 'date-fns';
import React, { useState } from 'react';
import { useLicenseConfirmation } from '../../hooks/useLicenseConfirmation';
import { useToggle } from '../../hooks/useToggle';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { PlainInput } from '../core/PlainInput';
import { HStack, VStack } from '../core/Stacks';
import { openUrl } from '@tauri-apps/plugin-opener';
export function SettingsLicense() {
const { check, activate } = useLicense();
const [key, setKey] = useState<string>('');
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
const [checked, setChecked] = useState<boolean>(false);
if (check.isPending) {
return null;
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 max-w-lg">
{check.data?.type === 'commercial_use' ? (
<Banner color="success">
<strong>License active!</strong> Enjoy using Yaak for commercial use.
</Banner>
) : (
<Banner color="primary" className="flex flex-col gap-3 max-w-lg">
{check.data?.type === 'trialing' && (
<p className="select-text">
<strong>
You have {formatDistanceToNowStrict(check.data.end)} remaining on your trial.
</strong>
</p>
)}
) : check.data?.type == 'trialing' ? (
<Banner color="success" className="flex flex-col gap-3 max-w-lg">
<p className="select-text">
A commercial license is required if using Yaak within a for-profit organization.{' '}
<Link href="https://yaak.app/pricing" className="text-notice">
Learn More
</Link>
<strong>{formatDistanceToNowStrict(check.data.end)} days remaining</strong> on your
commercial use trial
</p>
</Banner>
)}
) : check.data?.type == 'personal_use' && !licenseDetails?.confirmedPersonalUse ? (
<Banner color="success" className="flex flex-col gap-3 max-w-lg">
<p className="select-text">
Your 30-day trial has ended. Please activate a license or confirm how you&apos;re using
Yaak.
</p>
<form
className="flex flex-col gap-3 items-start"
onSubmit={async (e) => {
e.preventDefault();
await setLicenseDetails((v) => ({
...v,
confirmedPersonalUse: true,
}));
}}
>
<Checkbox
checked={checked}
onChange={setChecked}
title="I am only using Yaak for personal use"
/>
<Button type="submit" disabled={!checked} size="xs" variant="border" color="success">
Confirm
</Button>
</form>
</Banner>
) : null}
<p className="select-text">
A commercial license is required if using Yaak within a for-profit organization.{' '}
<Link href="https://yaak.app/pricing" className="text-notice">
Learn More
</Link>
</p>
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
@@ -80,7 +108,7 @@ export function SettingsLicense() {
<Button
color="secondary"
size="sm"
onClick={() => open('https://yaak.app/pricing?ref=app.yaak.desktop')}
onClick={() => openUrl('https://yaak.app/pricing?ref=app.yaak.desktop')}
rightSlot={<Icon icon="external_link" />}
event="license.purchase"
>

View File

@@ -65,7 +65,7 @@ export function SettingsDropdown() {
label: 'Feedback',
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" />,
onSelect: () => openUrl('https://yaak.app/roadmap'),
onSelect: () => openUrl('https://yaak.app/feedback'),
},
{
label: 'Changelog',

View File

@@ -55,7 +55,7 @@ export type DropdownItemDefault = {
label: ReactNode;
hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean;
color?: 'default' | 'danger' | 'info' | 'warning' | 'notice';
color?: 'default' | 'danger' | 'info' | 'warning' | 'notice' | 'success';
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
@@ -111,20 +111,23 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
const setIsOpen = useCallback((o: SetStateAction<boolean>) => {
jotaiStore.set(openAtom, (prevId) => {
const prevIsOpen = prevId === id.current;
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
// Persist background color of button until we close the dropdown
if (newIsOpen) {
onOpen?.();
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
});
}, [onOpen]);
const setIsOpen = useCallback(
(o: SetStateAction<boolean>) => {
jotaiStore.set(openAtom, (prevId) => {
const prevIsOpen = prevId === id.current;
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
// Persist background color of button until we close the dropdown
if (newIsOpen) {
onOpen?.();
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
});
},
[onOpen],
);
// Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
// we have of detecting the dropdown closed, to do cleanup.
@@ -642,6 +645,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-surface-highlight focus:text rounded',
item.color === 'danger' && '!text-danger',
item.color === 'success' && '!text-success',
item.color === 'warning' && '!text-warning',
item.color === 'notice' && '!text-notice',
item.color === 'info' && '!text-info',

View File

@@ -115,7 +115,6 @@ export function useHotKey(
if (e.metaKey) currentKeysWithModifiers.add('Meta');
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
console.log('down', currentKeysWithModifiers);
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
if (
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&

View File

@@ -0,0 +1,15 @@
import { useKeyValue } from './useKeyValue';
interface LicenseConfirmation {
hasDismissedTrial: boolean;
confirmedPersonalUse: boolean;
}
export function useLicenseConfirmation() {
const { set, value } = useKeyValue<LicenseConfirmation>({
key: 'license_confirmation',
fallback: { hasDismissedTrial: false, confirmedPersonalUse: false },
});
return [value, set] as const;
}