- Activation, deactivation and verify POST to /v1/licenses/* with
"product":"simpl-resultat" instead of the legacy /licenses/* paths,
which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
read from error.code of the /v1 envelope. A 200 {valid:false} from
verify is now a coded refusal instead of an empty machine list, and an
HTML error page on a non-2xx maps to server instead of
invalid_response. Deactivation is idempotent: no_active_activation
counts as done.
- The three calls live in *_inner functions without AppHandle, tested
against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
survives the list load it triggers (machine_limit_reached opens the
list), a deactivation removes the machine locally without re-running
verify (4 requests on the machine-limit path, under the server's
5/min/IP limit), and refresh() follows every successful activation or
deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
/{fr|en}/produits/simpl-resultat.
test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.
Fixes #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
export type Edition = "free" | "base" | "premium";
|
|
|
|
export interface LicenseInfo {
|
|
edition: Edition;
|
|
email: string;
|
|
features: string[];
|
|
machine_limit: number;
|
|
issued_at: number;
|
|
expires_at: number;
|
|
}
|
|
|
|
export async function validateLicenseKey(key: string): Promise<LicenseInfo> {
|
|
return invoke<LicenseInfo>("validate_license_key", { key });
|
|
}
|
|
|
|
export async function storeLicense(key: string): Promise<LicenseInfo> {
|
|
return invoke<LicenseInfo>("store_license", { key });
|
|
}
|
|
|
|
export async function readLicense(): Promise<LicenseInfo | null> {
|
|
return invoke<LicenseInfo | null>("read_license");
|
|
}
|
|
|
|
export async function getEdition(): Promise<Edition> {
|
|
return invoke<Edition>("get_edition");
|
|
}
|
|
|
|
export async function getMachineId(): Promise<string> {
|
|
return invoke<string>("get_machine_id");
|
|
}
|
|
|
|
export async function checkEntitlement(feature: string): Promise<boolean> {
|
|
return invoke<boolean>("check_entitlement", { feature });
|
|
}
|
|
|
|
export interface MachineInfo {
|
|
machine_id: string;
|
|
machine_name: string | null;
|
|
activated_at: string;
|
|
last_seen_at: string;
|
|
}
|
|
|
|
export interface ActivationStatus {
|
|
is_activated: boolean;
|
|
machine_id: string;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// License server errors (issue #270)
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
// The network commands (activate_machine, deactivate_machine,
|
|
// list_activated_machines) reject with a JSON string serialized from the Rust
|
|
// `LicenseError` enum (license_commands.rs), same pattern as fetch_price:
|
|
// {"code":"license_revoked"} | {"code":"rate_limit","retry_after_s":42}
|
|
// | {"code":"server","status":502} | ...
|
|
// `unknown` is the client-side fallback for anything that is not such a string.
|
|
|
|
export type LicenseErrorCode =
|
|
| "license_not_found"
|
|
| "license_revoked"
|
|
| "license_expired"
|
|
| "machine_limit_reached"
|
|
| "no_active_activation"
|
|
| "invalid_input"
|
|
| "rate_limit"
|
|
| "network"
|
|
| "invalid_response"
|
|
| "server"
|
|
| "no_license_stored"
|
|
| "storage"
|
|
| "internal"
|
|
| "unknown";
|
|
|
|
export interface LicenseError {
|
|
code: LicenseErrorCode;
|
|
i18nKey: string;
|
|
/** Interpolation values for `i18nKey` — only `rate_limit` carries one. */
|
|
params?: { seconds: number };
|
|
}
|
|
|
|
export const LICENSE_ERROR_I18N_MAP: Record<LicenseErrorCode, string> = {
|
|
license_not_found: "license.errors.licenseNotFound",
|
|
license_revoked: "license.errors.licenseRevoked",
|
|
license_expired: "license.errors.licenseExpired",
|
|
machine_limit_reached: "license.errors.machineLimitReached",
|
|
no_active_activation: "license.errors.noActiveActivation",
|
|
invalid_input: "license.errors.invalidInput",
|
|
rate_limit: "license.errors.rateLimit",
|
|
network: "license.errors.network",
|
|
invalid_response: "license.errors.invalidResponse",
|
|
server: "license.errors.server",
|
|
no_license_stored: "license.errors.noLicenseStored",
|
|
storage: "license.errors.storage",
|
|
internal: "license.errors.internal",
|
|
unknown: "license.errors.unknown",
|
|
};
|
|
|
|
/** Wait shown when a 429 carries no usable retry_after (the server window is 60 s). */
|
|
const DEFAULT_RETRY_AFTER_S = 60;
|
|
|
|
function isLicenseErrorCode(code: string): code is LicenseErrorCode {
|
|
return Object.prototype.hasOwnProperty.call(LICENSE_ERROR_I18N_MAP, code);
|
|
}
|
|
|
|
/**
|
|
* Parse the string-serialized Rust `LicenseError` into a typed error with its
|
|
* i18n key. `invoke` rejects with the `Err(String)` value of the command.
|
|
*/
|
|
export function parseLicenseError(e: unknown): LicenseError {
|
|
if (typeof e === "string") {
|
|
try {
|
|
const j = JSON.parse(e) as Record<string, unknown> | null;
|
|
if (j && typeof j.code === "string" && isLicenseErrorCode(j.code)) {
|
|
const code = j.code;
|
|
if (code === "rate_limit") {
|
|
const raw = j.retry_after_s;
|
|
const seconds =
|
|
typeof raw === "number" && Number.isFinite(raw) && raw >= 1
|
|
? Math.ceil(raw)
|
|
: DEFAULT_RETRY_AFTER_S;
|
|
return { code, i18nKey: LICENSE_ERROR_I18N_MAP.rate_limit, params: { seconds } };
|
|
}
|
|
return { code, i18nKey: LICENSE_ERROR_I18N_MAP[code] };
|
|
}
|
|
} catch {
|
|
// Not a serialized LicenseError — fall through to `unknown`.
|
|
}
|
|
}
|
|
return { code: "unknown", i18nKey: LICENSE_ERROR_I18N_MAP.unknown };
|
|
}
|
|
|
|
export type LicenseResult<T> = { ok: true; value: T } | { ok: false; error: LicenseError };
|
|
|
|
async function callLicenseCommand<T>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
): Promise<LicenseResult<T>> {
|
|
try {
|
|
const value = await invoke<T>(command, args);
|
|
return { ok: true, value };
|
|
} catch (e) {
|
|
return { ok: false, error: parseLicenseError(e) };
|
|
}
|
|
}
|
|
|
|
export function activateMachine(): Promise<LicenseResult<void>> {
|
|
return callLicenseCommand<void>("activate_machine");
|
|
}
|
|
|
|
export function deactivateMachine(machineId: string): Promise<LicenseResult<void>> {
|
|
return callLicenseCommand<void>("deactivate_machine", { machineId });
|
|
}
|
|
|
|
export function listActivatedMachines(): Promise<LicenseResult<MachineInfo[]>> {
|
|
return callLicenseCommand<MachineInfo[]>("list_activated_machines");
|
|
}
|
|
|
|
export async function getActivationStatus(): Promise<ActivationStatus> {
|
|
return invoke<ActivationStatus>("get_activation_status");
|
|
}
|