Add ability to deactivate license

This commit is contained in:
Gregory Schier
2025-03-05 07:13:19 -08:00
parent 9ead45d67a
commit 7a1a0689b0
16 changed files with 218 additions and 36 deletions

File diff suppressed because one or more lines are too long

View File

@@ -5572,6 +5572,11 @@
"type": "string", "type": "string",
"const": "yaak-license:allow-check" "const": "yaak-license:allow-check"
}, },
{
"description": "Enables the deactivate command without any pre-configured scope.",
"type": "string",
"const": "yaak-license:allow-deactivate"
},
{ {
"description": "Denies the activate command without any pre-configured scope.", "description": "Denies the activate command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -5582,6 +5587,11 @@
"type": "string", "type": "string",
"const": "yaak-license:deny-check" "const": "yaak-license:deny-check"
}, },
{
"description": "Denies the deactivate command without any pre-configured scope.",
"type": "string",
"const": "yaak-license:deny-deactivate"
},
{ {
"description": "Default permissions for the plugin", "description": "Default permissions for the plugin",
"type": "string", "type": "string",

View File

@@ -5572,6 +5572,11 @@
"type": "string", "type": "string",
"const": "yaak-license:allow-check" "const": "yaak-license:allow-check"
}, },
{
"description": "Enables the deactivate command without any pre-configured scope.",
"type": "string",
"const": "yaak-license:allow-deactivate"
},
{ {
"description": "Denies the activate command without any pre-configured scope.", "description": "Denies the activate command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -5582,6 +5587,11 @@
"type": "string", "type": "string",
"const": "yaak-license:deny-check" "const": "yaak-license:deny-check"
}, },
{
"description": "Denies the deactivate command without any pre-configured scope.",
"type": "string",
"const": "yaak-license:deny-deactivate"
},
{ {
"description": "Default permissions for the plugin", "description": "Default permissions for the plugin",
"type": "string", "type": "string",

View File

@@ -8,4 +8,6 @@ export type ActivateLicenseResponsePayload = { activationId: string, };
export type CheckActivationResponsePayload = { active: boolean, }; export type CheckActivationResponsePayload = { active: boolean, };
export type DeactivateLicenseRequestPayload = { licenseKey: string, 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 = { "type": "personal_use", trial_ended: string, } | { "type": "commercial_use" } | { "type": "invalid_license" } | { "type": "trialing", end: string, };

View File

@@ -1,4 +1,4 @@
const COMMANDS: &[&str] = &["activate", "check"]; const COMMANDS: &[&str] = &["activate", "deactivate", "check"];
fn main() { fn main() {
tauri_plugin::Builder::new(COMMANDS).build(); tauri_plugin::Builder::new(COMMANDS).build();

View File

@@ -14,6 +14,12 @@ export function useLicense() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: CHECK_QUERY_KEY }), onSuccess: () => queryClient.invalidateQueries({ queryKey: CHECK_QUERY_KEY }),
}); });
const deactivate = useMutation<void, string, void>({
mutationKey: ['license.deactivate'],
mutationFn: () => invoke('plugin:yaak-license|deactivate'),
onSuccess: () => queryClient.invalidateQueries({ queryKey: CHECK_QUERY_KEY }),
});
// Check the license again after a license is activated // Check the license again after a license is activated
useEffect(() => { useEffect(() => {
const unlisten = listen('license-activated', async () => { const unlisten = listen('license-activated', async () => {
@@ -27,12 +33,14 @@ export function useLicense() {
const CHECK_QUERY_KEY = ['license.check']; const CHECK_QUERY_KEY = ['license.check'];
const check = useQuery<void, string, LicenseCheckStatus>({ const check = useQuery<void, string, LicenseCheckStatus>({
refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours
refetchOnWindowFocus: false,
queryKey: CHECK_QUERY_KEY, queryKey: CHECK_QUERY_KEY,
queryFn: () => invoke('plugin:yaak-license|check'), queryFn: () => invoke('plugin:yaak-license|check'),
}); });
return { return {
activate, activate,
deactivate,
check, check,
} as const; } as const;
} }

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-deactivate"
description = "Enables the deactivate command without any pre-configured scope."
commands.allow = ["deactivate"]
[[permission]]
identifier = "deny-deactivate"
description = "Denies the deactivate command without any pre-configured scope."
commands.deny = ["deactivate"]

View File

@@ -4,6 +4,7 @@ Default permissions for the plugin
- `allow-check` - `allow-check`
- `allow-activate` - `allow-activate`
- `allow-deactivate`
## Permission Table ## Permission Table
@@ -63,6 +64,32 @@ Enables the check command without any pre-configured scope.
Denies the check command without any pre-configured scope. Denies the check command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-license:allow-deactivate`
</td>
<td>
Enables the deactivate command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-license:deny-deactivate`
</td>
<td>
Denies the deactivate command without any pre-configured scope.
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -1,3 +1,3 @@
[default] [default]
description = "Default permissions for the plugin" description = "Default permissions for the plugin"
permissions = ["allow-check", "allow-activate"] permissions = ["allow-check", "allow-activate", "allow-deactivate"]

View File

@@ -314,6 +314,16 @@
"type": "string", "type": "string",
"const": "deny-check" "const": "deny-check"
}, },
{
"description": "Enables the deactivate command without any pre-configured scope.",
"type": "string",
"const": "allow-deactivate"
},
{
"description": "Denies the deactivate command without any pre-configured scope.",
"type": "string",
"const": "deny-deactivate"
},
{ {
"description": "Default permissions for the plugin", "description": "Default permissions for the plugin",
"type": "string", "type": "string",

View File

@@ -1,5 +1,5 @@
use crate::errors::Result; use crate::errors::Result;
use crate::{activate_license, check_license, ActivateLicenseRequestPayload, LicenseCheckStatus}; use crate::{activate_license, check_license, deactivate_license, get_activation_id, ActivateLicenseRequestPayload, CheckActivationRequestPayload, DeactivateLicenseRequestPayload, LicenseCheckStatus};
use log::{debug, info}; use log::{debug, info};
use std::string::ToString; use std::string::ToString;
use tauri::{command, AppHandle, Manager, Runtime, WebviewWindow}; use tauri::{command, AppHandle, Manager, Runtime, WebviewWindow};
@@ -7,7 +7,10 @@ use tauri::{command, AppHandle, Manager, Runtime, WebviewWindow};
#[command] #[command]
pub async fn check<R: Runtime>(app_handle: AppHandle<R>) -> Result<LicenseCheckStatus> { pub async fn check<R: Runtime>(app_handle: AppHandle<R>) -> Result<LicenseCheckStatus> {
debug!("Checking license"); debug!("Checking license");
check_license(&app_handle).await check_license(&app_handle, CheckActivationRequestPayload{
app_platform: get_os().to_string(),
app_version: app_handle.package_info().version.to_string(),
}).await
} }
#[command] #[command]
@@ -24,6 +27,19 @@ pub async fn activate<R: Runtime>(license_key: &str, window: WebviewWindow<R>) -
.await .await
} }
#[command]
pub async fn deactivate<R: Runtime>(window: WebviewWindow<R>) -> Result<()> {
info!("Deactivating activation");
deactivate_license(
&window,
DeactivateLicenseRequestPayload {
app_platform: get_os().to_string(),
app_version: window.app_handle().package_info().version.to_string(),
},
)
.await
}
fn get_os() -> &'static str { fn get_os() -> &'static str {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
"windows" "windows"

View File

@@ -8,9 +8,11 @@ mod commands;
mod errors; mod errors;
mod license; mod license;
use crate::commands::{activate, check}; use crate::commands::{activate, check, deactivate};
pub use license::*; pub use license::*;
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-license").invoke_handler(generate_handler![check, activate]).build() Builder::new("yaak-license")
.invoke_handler(generate_handler![check, activate, deactivate])
.build()
} }

View File

@@ -5,7 +5,7 @@ use log::{debug, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::ops::Add; use std::ops::Add;
use std::time::Duration; use std::time::Duration;
use tauri::{is_dev, AppHandle, Emitter, Runtime, WebviewWindow}; use tauri::{is_dev, AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS; use ts_rs::TS;
use yaak_models::queries::UpdateSource; use yaak_models::queries::UpdateSource;
@@ -17,7 +17,8 @@ const TRIAL_SECONDS: u64 = 3600 * 24 * 30;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")] #[ts(export, export_to = "gen_models.ts")]
pub struct CheckActivationRequestPayload { pub struct CheckActivationRequestPayload {
pub activation_id: String, pub app_version: String,
pub app_platform: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -36,6 +37,14 @@ pub struct ActivateLicenseRequestPayload {
pub app_platform: String, pub app_platform: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "license.ts")]
pub struct DeactivateLicenseRequestPayload {
pub app_version: String,
pub app_platform: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "license.ts")] #[ts(export, export_to = "license.ts")]
@@ -56,7 +65,7 @@ pub async fn activate_license<R: Runtime>(
p: ActivateLicenseRequestPayload, p: ActivateLicenseRequestPayload,
) -> Result<()> { ) -> Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let response = client.post(build_url("/activate")).json(&p).send().await?; let response = client.post(build_url("/licenses/activate")).json(&p).send().await?;
if response.status().is_client_error() { if response.status().is_client_error() {
let body: APIErrorResponsePayload = response.json().await?; let body: APIErrorResponsePayload = response.json().await?;
@@ -86,6 +95,44 @@ pub async fn activate_license<R: Runtime>(
Ok(()) Ok(())
} }
pub async fn deactivate_license<R: Runtime>(
window: &WebviewWindow<R>,
p: DeactivateLicenseRequestPayload,
) -> Result<()> {
let activation_id = get_activation_id(window).await;
let client = reqwest::Client::new();
let path = format!("/licenses/activations/{}/deactivate", activation_id);
let response = client.post(build_url(&path)).json(&p).send().await?;
if response.status().is_client_error() {
let body: APIErrorResponsePayload = response.json().await?;
return Err(ClientError {
message: body.message,
error: body.error,
});
}
if response.status().is_server_error() {
return Err(ServerError);
}
yaak_models::queries::delete_key_value(
window,
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
&UpdateSource::Window,
)
.await;
if let Err(e) = window.emit("license-deactivated", true) {
warn!("Failed to emit deactivate-license event: {}", e);
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")] #[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "license.ts")] #[ts(export, export_to = "license.ts")]
@@ -96,15 +143,8 @@ pub enum LicenseCheckStatus {
Trialing { end: NaiveDateTime }, Trialing { end: NaiveDateTime },
} }
pub async fn check_license<R: Runtime>(app_handle: &AppHandle<R>) -> Result<LicenseCheckStatus> { pub async fn check_license<R: Runtime>(app_handle: &AppHandle<R>, payload: CheckActivationRequestPayload) -> Result<LicenseCheckStatus> {
let activation_id = yaak_models::queries::get_key_value_string( let activation_id = get_activation_id(app_handle).await;
app_handle,
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
"",
)
.await;
let settings = yaak_models::queries::get_or_create_settings(app_handle).await; let settings = yaak_models::queries::get_or_create_settings(app_handle).await;
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS)); let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS));
@@ -122,10 +162,8 @@ pub async fn check_license<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Lice
info!("Checking license activation"); info!("Checking license activation");
// A license has been activated, so let's check the license server // A license has been activated, so let's check the license server
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let payload = CheckActivationRequestPayload { let path = format!("/licenses/activations/{activation_id}/check");
activation_id: activation_id.clone(), let response = client.post(build_url(&path)).json(&payload).send().await?;
};
let response = client.post(build_url("/check")).json(&payload).send().await?;
if response.status().is_client_error() { if response.status().is_client_error() {
let body: APIErrorResponsePayload = response.json().await?; let body: APIErrorResponsePayload = response.json().await?;
@@ -151,8 +189,13 @@ pub async fn check_license<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Lice
fn build_url(path: &str) -> String { fn build_url(path: &str) -> String {
if is_dev() { if is_dev() {
format!("http://localhost:9444/licenses{path}") format!("http://localhost:9444{path}")
} else { } else {
format!("https://license.yaak.app/licenses{path}") format!("https://license.yaak.app{path}")
} }
} }
pub async fn get_activation_id<R: Runtime>(mgr: &impl Manager<R>) -> String {
yaak_models::queries::get_key_value_string(mgr, KV_ACTIVATION_ID_KEY, KV_NAMESPACE, "")
.await
}

View File

@@ -134,6 +134,31 @@ pub async fn set_key_value_raw<R: Runtime>(
(m, existing.is_none()) (m, existing.is_none())
} }
pub async fn delete_key_value<R: Runtime>(
w: &WebviewWindow<R>,
namespace: &str,
key: &str,
update_source: &UpdateSource,
) {
let kv = match get_key_value_raw(w, namespace, key).await {
None => return,
Some(m) => m,
};
let dbm = &*w.state::<SqliteConnection>();
let db = dbm.0.lock().await.get().unwrap();
let (sql, params) = Query::delete()
.from_table(KeyValueIden::Table)
.cond_where(
Cond::all()
.add(Expr::col(KeyValueIden::Namespace).eq(namespace))
.add(Expr::col(KeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
db.execute(sql.as_str(), &*params.as_params()).expect("Failed to delete PluginKeyValue");
emit_deleted_model(w, &AnyModel::KeyValue(kv.to_owned()), update_source);
}
pub async fn list_key_values_raw<R: Runtime>(mgr: &impl Manager<R>) -> Result<Vec<KeyValue>> { pub async fn list_key_values_raw<R: Runtime>(mgr: &impl Manager<R>) -> Result<Vec<KeyValue>> {
let dbm = &*mgr.state::<SqliteConnection>(); let dbm = &*mgr.state::<SqliteConnection>();
let db = dbm.0.lock().await.get().unwrap(); let db = dbm.0.lock().await.get().unwrap();

View File

@@ -27,13 +27,26 @@ const details: Record<
commercial_use: null, commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' }, invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'success' }, personal_use: { label: 'Personal Use', color: 'success' },
trialing: { label: 'Active Trial', color: 'success' }, trialing: { label: 'Personal Use', color: 'success' },
}; };
export function LicenseBadge() { export function LicenseBadge() {
const { check } = useLicense(); const { check } = useLicense();
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation(); const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
if (check.error) {
return (
<LicenseBadgeButton
color="danger"
onClick={() => {
openSettings.mutate(SettingsTab.License);
}}
>
License Error
</LicenseBadgeButton>
);
}
// Hasn't loaded yet // Hasn't loaded yet
if (licenseDetails == null || check.data == null) { if (licenseDetails == null || check.data == null) {
return null; return null;
@@ -56,10 +69,7 @@ export function LicenseBadge() {
} }
return ( return (
<Button <LicenseBadgeButton
size="2xs"
variant="border"
className="!rounded-full mx-1"
color={detail.color} color={detail.color}
onClick={async () => { onClick={async () => {
if (check.data.type === 'trialing') { if (check.data.type === 'trialing') {
@@ -72,6 +82,10 @@ export function LicenseBadge() {
}} }}
> >
{detail.label} {detail.label}
</Button> </LicenseBadgeButton>
); );
} }
function LicenseBadgeButton({ ...props }: ButtonProps) {
return <Button size="2xs" variant="border" className="!rounded-full mx-1" {...props} />;
}

View File

@@ -14,7 +14,7 @@ import { PlainInput } from '../core/PlainInput';
import { HStack, VStack } from '../core/Stacks'; import { HStack, VStack } from '../core/Stacks';
export function SettingsLicense() { export function SettingsLicense() {
const { check, activate } = useLicense(); const { check, activate, deactivate } = useLicense();
const [key, setKey] = useState<string>(''); const [key, setKey] = useState<string>('');
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false); const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation(); const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
@@ -37,8 +37,8 @@ export function SettingsLicense() {
<strong> <strong>
{pluralizeCount('day', differenceInDays(check.data.end, new Date()))} remaining {pluralizeCount('day', differenceInDays(check.data.end, new Date()))} remaining
</strong>{' '} </strong>{' '}
on your commercial use trial. Once the trial ends, Yaak will be limited to personal use on your commercial use trial. Once the trial ends you agree to only use Yaak for
until a license is activated. personal use until a license is activated.
</p> </p>
</Banner> </Banner>
) : check.data?.type == 'personal_use' && !licenseDetails?.confirmedPersonalUse ? ( ) : check.data?.type == 'personal_use' && !licenseDetails?.confirmedPersonalUse ? (
@@ -81,8 +81,10 @@ export function SettingsLicense() {
{check.data?.type === 'commercial_use' ? ( {check.data?.type === 'commercial_use' ? (
<HStack space={2}> <HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}> <Button variant="border" color="secondary" size="sm" onClick={() => {
Activate Another License deactivate.mutate();
}}>
Deactivate License
</Button> </Button>
<Button <Button
color="secondary" color="secondary"