Skip to main content

tauri_plugin_licensemanager/core/
error.rs

1//! Port of `license-errors.ts`.
2
3use serde::{Deserialize, Serialize};
4
5/// Error codes returned by the validate-key endpoint, plus SDK-side codes for
6/// failures that never reached a definitive server answer.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum LicenseErrorCode {
10    /// The key does not exist for this product.
11    NotFound,
12    /// The license expiration date has passed.
13    LicenseExpired,
14    /// The license doesn't include this app version.
15    VersionNotAllowed,
16    /// The license is active on its maximum number of machines.
17    MachineLimitReached,
18    /// The vendor's plan limit was exceeded.
19    PlanLimitExceeded,
20    /// The request was refused as malformed (or the key was empty).
21    ValidationError,
22    /// The server failed.
23    InternalError,
24    /// Server unreachable, timed out, or answered with an unknown error.
25    NetworkError,
26    /// Server answered 2xx but its token was missing or failed verification.
27    InvalidResponse,
28}
29
30impl LicenseErrorCode {
31    /// The wire string, e.g. `"not_found"`.
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::NotFound => "not_found",
35            Self::LicenseExpired => "license_expired",
36            Self::VersionNotAllowed => "version_not_allowed",
37            Self::MachineLimitReached => "machine_limit_reached",
38            Self::PlanLimitExceeded => "plan_limit_exceeded",
39            Self::ValidationError => "validation_error",
40            Self::InternalError => "internal_error",
41            Self::NetworkError => "network_error",
42            Self::InvalidResponse => "invalid_response",
43        }
44    }
45
46    /// Codes the server may send that are reported as-is; any other server
47    /// code becomes [`LicenseErrorCode::NetworkError`].
48    pub(crate) fn from_server_code(code: &str) -> Option<Self> {
49        match code {
50            "not_found" => Some(Self::NotFound),
51            "license_expired" => Some(Self::LicenseExpired),
52            "version_not_allowed" => Some(Self::VersionNotAllowed),
53            "machine_limit_reached" => Some(Self::MachineLimitReached),
54            "plan_limit_exceeded" => Some(Self::PlanLimitExceeded),
55            "validation_error" => Some(Self::ValidationError),
56            "internal_error" => Some(Self::InternalError),
57            _ => None,
58        }
59    }
60
61    /// True for codes that say something about the customer's license, and
62    /// therefore lock the app. Every other failure is treated like the server
63    /// being unreachable.
64    pub fn is_locking(self) -> bool {
65        matches!(
66            self,
67            Self::NotFound
68                | Self::LicenseExpired
69                | Self::VersionNotAllowed
70                | Self::MachineLimitReached
71        )
72    }
73}
74
75impl std::fmt::Display for LicenseErrorCode {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(self.as_str())
78    }
79}
80
81/// In state `VERSION_NOT_ALLOWED`, the latest version the license allows.
82///
83/// Electron's `string | null | undefined`.
84#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
85pub enum LatestAllowedVersion {
86    /// Not a server version refusal (`undefined` in JS): any other state, or
87    /// a refusal by the stored token while offline, which only knows the max
88    /// version, not the latest release.
89    #[default]
90    NotApplicable,
91    /// The license allows no version (`null` in JS).
92    NoneAllowed,
93    /// The latest version the license allows.
94    Version(String),
95}
96
97impl LatestAllowedVersion {
98    /// `None` for [`NotApplicable`](Self::NotApplicable), else `Some(None)`
99    /// or `Some(Some(version))`.
100    pub fn as_option(&self) -> Option<Option<&str>> {
101        match self {
102            Self::NotApplicable => None,
103            Self::NoneAllowed => Some(None),
104            Self::Version(v) => Some(Some(v)),
105        }
106    }
107}
108
109/// A failed license validation. Branch on [`code`](Self::code), never on
110/// `message`.
111#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
112#[error("{message}")]
113pub struct LicenseError {
114    /// What went wrong.
115    pub code: LicenseErrorCode,
116    /// Human-readable description, for logs.
117    pub message: String,
118    /// HTTP status, when the server answered.
119    pub status: Option<u16>,
120    /// For `version_not_allowed`: latest version the license allows.
121    pub latest_allowed_version: LatestAllowedVersion,
122}
123
124impl LicenseError {
125    pub(crate) fn new(code: LicenseErrorCode, message: impl Into<String>) -> Self {
126        Self {
127            code,
128            message: message.into(),
129            status: None,
130            latest_allowed_version: LatestAllowedVersion::NotApplicable,
131        }
132    }
133
134    pub(crate) fn with_status(mut self, status: u16) -> Self {
135        self.status = Some(status);
136        self
137    }
138
139    /// True when the license itself was refused (the app should lock).
140    pub fn is_locking(&self) -> bool {
141        self.code.is_locking()
142    }
143}
144
145/// Invalid plugin configuration: a developer error, reported at startup.
146#[derive(Debug, PartialEq, Eq, thiserror::Error)]
147pub enum ConfigError {
148    /// `product_id` is empty.
149    #[error("productId is required")]
150    MissingProductId,
151    /// `public_key` is empty.
152    #[error("publicKey is required")]
153    MissingPublicKey,
154    /// `public_key` is not an Ed25519 SPKI PEM.
155    #[error("publicKey is not a valid Ed25519 SPKI PEM: {0}")]
156    InvalidPublicKey(String),
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn serializes_codes_like_electron() {
165        for code in [
166            LicenseErrorCode::NotFound,
167            LicenseErrorCode::LicenseExpired,
168            LicenseErrorCode::VersionNotAllowed,
169            LicenseErrorCode::MachineLimitReached,
170            LicenseErrorCode::PlanLimitExceeded,
171            LicenseErrorCode::ValidationError,
172            LicenseErrorCode::InternalError,
173            LicenseErrorCode::NetworkError,
174            LicenseErrorCode::InvalidResponse,
175        ] {
176            assert_eq!(
177                serde_json::to_value(code).unwrap(),
178                serde_json::Value::String(code.to_string())
179            );
180            assert_eq!(
181                LicenseErrorCode::from_server_code(code.as_str()).is_some(),
182                !matches!(
183                    code,
184                    LicenseErrorCode::NetworkError | LicenseErrorCode::InvalidResponse
185                )
186            );
187        }
188    }
189
190    #[test]
191    fn locking_codes() {
192        assert!(LicenseError::new(LicenseErrorCode::NotFound, "x").is_locking());
193        assert!(LicenseErrorCode::LicenseExpired.is_locking());
194        assert!(LicenseErrorCode::VersionNotAllowed.is_locking());
195        assert!(LicenseErrorCode::MachineLimitReached.is_locking());
196        assert!(!LicenseErrorCode::PlanLimitExceeded.is_locking());
197        assert!(!LicenseErrorCode::NetworkError.is_locking());
198    }
199
200    #[test]
201    fn latest_allowed_version_as_option() {
202        assert_eq!(LatestAllowedVersion::NotApplicable.as_option(), None);
203        assert_eq!(LatestAllowedVersion::NoneAllowed.as_option(), Some(None));
204        assert_eq!(
205            LatestAllowedVersion::Version("2.3".into()).as_option(),
206            Some(Some("2.3"))
207        );
208    }
209
210    #[test]
211    fn config_error_messages() {
212        assert_eq!(
213            ConfigError::MissingProductId.to_string(),
214            "productId is required"
215        );
216        assert_eq!(
217            ConfigError::MissingPublicKey.to_string(),
218            "publicKey is required"
219        );
220    }
221}