feat: Maximus Account OAuth2 PKCE + machine activation + subscription check (#51, #53)
All checks were successful
PR Check / rust (push) Successful in 16m34s
PR Check / frontend (push) Successful in 2m14s
PR Check / rust (pull_request) Successful in 16m31s
PR Check / frontend (pull_request) Successful in 2m13s

- Add auth_commands.rs: OAuth2 PKCE flow (start_oauth, handle_auth_callback,
  refresh_auth_token, get_account_info, check_subscription_status, logout)
- Add deep-link handler in lib.rs for simpl-resultat://auth/callback
- Add AccountCard.tsx + useAuth hook + authService.ts
- Add machine activation commands (activate, deactivate, list, get_activation_status)
- Extend LicenseCard with machine management UI
- get_edition() now checks account subscription for Premium detection
- Daily subscription status check (refresh token if last check > 24h)
- Configure CSP for API/auth endpoints
- Configure tauri-plugin-deep-link for desktop
- Update i18n (FR/EN), changelogs, and architecture docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-10 14:18:51 -04:00
parent 877ace370f
commit b53a902f11
19 changed files with 1746 additions and 29 deletions

1
.gitignore vendored
View file

@ -51,3 +51,4 @@ public/CHANGELOG.fr.md
# Tauri generated
src-tauri/gen/
.keys-temp/

View file

@ -5,9 +5,13 @@
### Ajouté
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
- Carte Compte Maximus dans les Paramètres : connexion optionnelle via OAuth2 PKCE pour les fonctionnalités Premium (#51)
- Activation de machines : activer/désactiver des machines via le serveur de licences, voir les machines activées dans la carte licence (#53)
- Vérification quotidienne de l'abonnement : rafraîchit automatiquement les infos du compte une fois par jour au lancement (#51)
### Modifié
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48)
- La détection d'édition prend maintenant en compte l'abonnement Compte Maximus : Premium remplace Base quand l'abonnement est actif (#51)
## [0.6.7] - 2026-03-29

View file

@ -5,9 +5,13 @@
### Added
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
- Maximus Account card in Settings: optional sign-in via OAuth2 PKCE for Premium features (#51)
- Machine activation: activate/deactivate machines against the license server, view activated machines in the license card (#53)
- Daily subscription status check: automatically refreshes account info once per day at launch (#51)
### Changed
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48)
- Edition detection now considers Maximus Account subscription: Premium overrides Base when subscription is active (#51)
## [0.6.7] - 2026-03-29

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-03-07 — Version 0.6.3
> Document mis à jour le 2026-04-10 — Version 0.6.7
## Stack technique
@ -26,7 +26,7 @@
```
simpl-resultat/
├── src/ # Frontend React/TypeScript
│ ├── components/ # 55 composants organisés par domaine
│ ├── components/ # 58 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
@ -35,11 +35,11 @@ simpl-resultat/
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
│ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants
│ ├── contexts/ # ProfileContext (état global profil)
│ ├── hooks/ # 12 hooks custom (useReducer)
│ ├── hooks/ # 13 hooks custom (useReducer)
│ ├── pages/ # 10 pages
│ ├── services/ # 14 services métier
│ ├── shared/ # Types et constantes partagés
@ -49,10 +49,13 @@ simpl-resultat/
│ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust
│ ├── src/
│ │ ├── commands/ # 3 modules de commandes Tauri
│ │ ├── commands/ # 6 modules de commandes Tauri
│ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs
│ │ │ └── profile_commands.rs
│ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs
│ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql
@ -107,7 +110,7 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
## Services TypeScript (15)
## Services TypeScript (17)
| Service | Responsabilité |
|---------|---------------|
@ -126,8 +129,10 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
## Hooks (12)
## Hooks (14)
Chaque hook encapsule la logique d'état via `useReducer` :
@ -144,9 +149,10 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useReports` | Données analytiques |
| `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
| `useLicense` | État de la licence et entitlements |
## Commandes Tauri (18)
## Commandes Tauri (25)
### `fs_commands.rs` — Système de fichiers (6)
@ -175,6 +181,19 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `verify_pin` — Vérification du PIN
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
### `license_commands.rs` — Licence (6)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `store_activation_token` — Stockage du token d'activation
- `read_license` — Lecture de la licence stockée
- `get_edition` — Détection de l'édition active (free/base/premium)
- `get_machine_id` — Génération d'un identifiant machine unique
### `entitlements.rs` — Entitlements (1)
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
## Pages et routing
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).

531
src-tauri/Cargo.lock generated
View file

@ -43,6 +43,18 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -562,6 +574,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@ -578,6 +610,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -601,9 +643,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@ -614,7 +656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@ -675,6 +717,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -732,6 +780,33 @@ dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling"
version = "0.21.3"
@ -897,6 +972,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@ -939,6 +1023,31 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
@ -1063,6 +1172,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@ -1078,6 +1199,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "field-offset"
version = "0.3.6"
@ -1138,6 +1265,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@ -1145,7 +1281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@ -1159,6 +1295,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@ -1417,8 +1559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -1591,12 +1735,40 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -1614,6 +1786,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hashlink"
version = "0.10.0"
@ -1674,6 +1855,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@ -1735,6 +1927,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@ -1762,6 +1955,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -1780,9 +1989,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry 0.6.1",
]
[[package]]
@ -2086,6 +2297,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@ -2219,6 +2445,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "machine-uid"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d7217d573cdb141d6da43113b098172e057d39915d79c4bdedbc3aacd46bd96"
dependencies = [
"libc",
"windows-registry 0.6.1",
"windows-sys 0.61.2",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@ -2329,6 +2566,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -2371,6 +2625,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@ -2693,18 +2957,66 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@ -2800,6 +3112,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@ -3338,6 +3660,46 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@ -3435,6 +3797,30 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.10.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink 0.9.1",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -3498,7 +3884,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
@ -3624,7 +4010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@ -3894,25 +4280,47 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simpl-result"
version = "0.3.0"
version = "0.6.7"
dependencies = [
"aes-gcm",
"argon2",
"ed25519-dalek",
"encoding_rs",
"hostname",
"jsonwebtoken",
"libsqlite3-sys",
"machine-uid",
"rand 0.8.5",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-sql",
"tauri-plugin-updater",
"tokio",
"urlencoding",
"walkdir",
]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "siphasher"
version = "0.3.11"
@ -4047,7 +4455,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"hashlink 0.10.0",
"indexmap 2.13.0",
"log",
"memchr",
@ -4320,6 +4728,27 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@ -4341,7 +4770,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.10.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@ -4431,7 +4860,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@ -4532,6 +4961,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
@ -4640,7 +5090,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
@ -4853,6 +5303,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@ -4889,9 +5348,31 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -5253,6 +5734,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@ -5703,6 +6190,28 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View file

@ -25,6 +25,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-deep-link = "2"
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
@ -37,6 +38,10 @@ argon2 = "0.5"
rand = "0.8"
jsonwebtoken = "9"
machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["macros"] }
hostname = "0.4"
urlencoding = "2"
[dev-dependencies]
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`

View file

@ -0,0 +1,405 @@
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
//
// Architecture:
// - The desktop app is registered as a "Native App" in Logto (public client, no secret).
// - OAuth2 Authorization Code + PKCE flow via the system browser.
// - Deep-link callback: simpl-resultat://auth/callback?code=...
// - Tokens are stored as files in app_data_dir/auth/ (encrypted at rest in a future
// iteration via OS keychain). For now, plain JSON — acceptable because:
// (a) the app data dir has user-only permissions,
// (b) the access token is short-lived (1h default in Logto),
// (c) the refresh token is rotated on each use.
//
// The PKCE verifier is held in memory via Tauri managed state, so it cannot be
// intercepted by another process. It is cleared after the callback exchange.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;
// Logto endpoint — overridable via env var for development.
fn logto_endpoint() -> String {
std::env::var("LOGTO_ENDPOINT")
.unwrap_or_else(|_| "https://auth.lacompagniemaximus.com".to_string())
}
// Logto app ID for the desktop native app.
fn logto_app_id() -> String {
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "simpl-resultat-desktop".to_string())
}
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const ACCOUNT_FILE: &str = "account.json";
const LAST_CHECK_FILE: &str = "last_check";
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
/// PKCE state held in memory during the OAuth2 flow.
pub struct OAuthState {
pub code_verifier: Mutex<Option<String>>,
}
/// Account info exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub email: String,
pub name: Option<String>,
pub picture: Option<String>,
pub subscription_status: Option<String>,
}
/// Stored tokens (written to auth/tokens.json).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredTokens {
access_token: String,
refresh_token: Option<String>,
id_token: Option<String>,
expires_at: i64,
}
fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?
.join(AUTH_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
}
Ok(dir)
}
fn generate_pkce() -> (String, String) {
use rand::Rng;
let mut rng = rand::thread_rng();
let verifier: String = (0..64)
.map(|_| {
let idx = rng.gen_range(0..62);
let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx];
c as char
})
.collect();
use sha2::{Digest, Sha256};
let hash = Sha256::digest(verifier.as_bytes());
let challenge = base64_url_encode(&hash);
(verifier, challenge)
}
fn base64_url_encode(data: &[u8]) -> String {
use base64_encode::encode;
encode(data)
.replace('+', "-")
.replace('/', "_")
.trim_end_matches('=')
.to_string()
}
// Simple base64 encoding without external dependency
mod base64_encode {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(data: &[u8]) -> String {
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let triple = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(CHARS[(triple & 0x3F) as usize] as char);
} else {
result.push('=');
}
}
result
}
}
/// Start the OAuth2 PKCE flow. Generates a code verifier/challenge, stores the verifier
/// in memory, and returns the authorization URL to open in the system browser.
#[tauri::command]
pub fn start_oauth(app: tauri::AppHandle) -> Result<String, String> {
let (verifier, challenge) = generate_pkce();
// Store verifier in managed state
let state = app.state::<OAuthState>();
*state.code_verifier.lock().unwrap() = Some(verifier);
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let url = format!(
"{}/oidc/auth?client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=openid%20profile%20email%20offline_access",
endpoint,
urlencoding::encode(&client_id),
urlencoding::encode(REDIRECT_URI),
urlencoding::encode(&challenge),
);
Ok(url)
}
/// Exchange the authorization code for tokens. Called from the deep-link callback handler.
#[tauri::command]
pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result<AccountInfo, String> {
let verifier = {
let state = app.state::<OAuthState>();
let verifier = state.code_verifier.lock().unwrap().take();
verifier.ok_or("No pending OAuth flow (verifier missing)")?
};
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "authorization_code"),
("client_id", &client_id),
("redirect_uri", REDIRECT_URI),
("code", &code),
("code_verifier", &verifier),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token exchange failed: {}", e))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Token exchange error: {}", body));
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let access_token = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let refresh_token = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let id_token = token_resp["id_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let expires_at = chrono_now() + expires_in;
// Store tokens
let tokens = StoredTokens {
access_token: access_token.clone(),
refresh_token,
id_token,
expires_at,
};
let dir = auth_dir(&app)?;
let tokens_json =
serde_json::to_string_pretty(&tokens).map_err(|e| format!("Serialize error: {}", e))?;
fs::write(dir.join(TOKENS_FILE), tokens_json)
.map_err(|e| format!("Cannot write tokens: {}", e))?;
// Fetch user info
let account = fetch_userinfo(&endpoint, &access_token).await?;
// Store account info
let account_json =
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
fs::write(dir.join(ACCOUNT_FILE), account_json)
.map_err(|e| format!("Cannot write account info: {}", e))?;
Ok(account)
}
/// Refresh the access token using the stored refresh token.
#[tauri::command]
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
let dir = auth_dir(&app)?;
let tokens_path = dir.join(TOKENS_FILE);
if !tokens_path.exists() {
return Err("Not authenticated".to_string());
}
let tokens_raw =
fs::read_to_string(&tokens_path).map_err(|e| format!("Cannot read tokens: {}", e))?;
let tokens: StoredTokens =
serde_json::from_str(&tokens_raw).map_err(|e| format!("Invalid tokens file: {}", e))?;
let refresh_token = tokens
.refresh_token
.ok_or("No refresh token available")?;
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "refresh_token"),
("client_id", &client_id),
("refresh_token", &refresh_token),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token refresh failed: {}", e))?;
if !resp.status().is_success() {
// Clear stored tokens on refresh failure
let _ = fs::remove_file(&tokens_path);
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
return Err("Session expired, please sign in again".to_string());
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let new_access = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let new_refresh = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let new_tokens = StoredTokens {
access_token: new_access.clone(),
refresh_token: new_refresh.or(Some(refresh_token)),
id_token: token_resp["id_token"].as_str().map(|s| s.to_string()),
expires_at: chrono_now() + expires_in,
};
let tokens_json = serde_json::to_string_pretty(&new_tokens)
.map_err(|e| format!("Serialize error: {}", e))?;
fs::write(&tokens_path, tokens_json)
.map_err(|e| format!("Cannot write tokens: {}", e))?;
let account = fetch_userinfo(&endpoint, &new_access).await?;
let account_json =
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
fs::write(dir.join(ACCOUNT_FILE), account_json)
.map_err(|e| format!("Cannot write account info: {}", e))?;
Ok(account)
}
/// Read cached account info without network call.
#[tauri::command]
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(&app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
let account: AccountInfo =
serde_json::from_str(&raw).map_err(|e| format!("Invalid account file: {}", e))?;
Ok(Some(account))
}
/// Log out: clear all stored tokens and account info.
#[tauri::command]
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(&app)?;
let _ = fs::remove_file(dir.join(TOKENS_FILE));
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
Ok(())
}
/// Check subscription status if the last check was more than 24h ago.
/// Returns the refreshed account info, or the cached info if no check was needed.
/// Graceful: returns Ok(None) if not authenticated, silently skips on network errors.
#[tauri::command]
pub async fn check_subscription_status(
app: tauri::AppHandle,
) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(&app)?;
// Not authenticated — nothing to check
if !dir.join(TOKENS_FILE).exists() {
return Ok(None);
}
let last_check_path = dir.join(LAST_CHECK_FILE);
let now = chrono_now();
// Check if we need to verify (more than 24h since last check)
if last_check_path.exists() {
if let Ok(raw) = fs::read_to_string(&last_check_path) {
if let Ok(ts) = raw.trim().parse::<i64>() {
if now - ts < CHECK_INTERVAL_SECS {
// Recent check — return cached account info
return get_account_info(app);
}
}
}
}
// Try to refresh the token to get fresh subscription status
match refresh_auth_token(app.clone()).await {
Ok(account) => {
// Update last check timestamp
let _ = fs::write(&last_check_path, now.to_string());
Ok(Some(account))
}
Err(_) => {
// Network error or expired session — graceful degradation.
// Still update the timestamp to avoid hammering on every launch.
let _ = fs::write(&last_check_path, now.to_string());
get_account_info(app)
}
}
}
async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInfo, String> {
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/oidc/me", endpoint))
.bearer_auth(access_token)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Userinfo fetch failed: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot fetch user info".to_string());
}
let info: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid userinfo response: {}", e))?;
Ok(AccountInfo {
email: info["email"]
.as_str()
.unwrap_or_default()
.to_string(),
name: info["name"].as_str().map(|s| s.to_string()),
picture: info["picture"].as_str().map(|s| s.to_string()),
subscription_status: info["custom_data"]["subscription_status"]
.as_str()
.map(|s| s.to_string()),
})
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}

View file

@ -22,12 +22,10 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification.
//
// IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
// The matching private key is publicly known, so any license signed with it offers no real
// protection. Replace this constant with the production public key before shipping a paid
// release. The corresponding private key MUST live only on the license server (Issue #49).
// Production key generated 2026-04-10. The corresponding private key lives ONLY
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM.
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\
-----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key";
@ -224,7 +222,16 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
/// failure resolves to "free" so feature gates fail closed.
///
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
// Check Compte Maximus subscription first — Premium overrides Base
if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM {
return edition;
}
}
let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string();
};
@ -260,6 +267,22 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
info.edition
}
/// Read the cached account.json to check for an active Premium subscription.
/// Returns None if no account file exists or the file is invalid.
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
let dir = app_data_dir(app).ok()?.join("auth");
let account_path = dir.join("account.json");
if !account_path.exists() {
return None;
}
let raw = fs::read_to_string(&account_path).ok()?;
let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?;
match account.subscription_status.as_deref() {
Some("active") => Some(EDITION_PREMIUM.to_string()),
_ => None,
}
}
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command]
@ -271,6 +294,202 @@ fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
}
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
fn api_base_url() -> String {
std::env::var("SIMPL_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Machine info returned by the license server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
pub machine_id: String,
pub machine_name: Option<String>,
pub activated_at: String,
pub last_seen_at: String,
}
/// Activation status for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationStatus {
pub is_activated: bool,
pub machine_id: String,
}
/// Activate this machine with the license server. Reads the stored license key, sends
/// the machine_id to the API, and stores the returned activation token.
#[tauri::command]
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let machine_id = machine_id_internal()?;
let machine_name = hostname::get()
.ok()
.and_then(|h| h.into_string().ok());
let url = format!("{}/licenses/activate", api_base_url());
let client = reqwest::Client::new();
let mut body = serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
});
if let Some(name) = machine_name {
body["machine_name"] = serde_json::Value::String(name);
}
let resp = client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
let resp_body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
if !status.is_success() {
let error = resp_body["error"]
.as_str()
.unwrap_or("Activation failed");
return Err(error.to_string());
}
let token = resp_body["activation_token"]
.as_str()
.ok_or("Server did not return an activation token")?;
// store_activation_token validates the token against local machine_id before writing
store_activation_token(app, token.to_string())?;
Ok(())
}
/// Deactivate a machine on the license server, freeing a slot.
#[tauri::command]
pub async fn deactivate_machine(
app: tauri::AppHandle,
machine_id: String,
) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/deactivate", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
if !status.is_success() {
let resp_body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({"error": "Deactivation failed"}));
let error = resp_body["error"].as_str().unwrap_or("Deactivation failed");
return Err(error.to_string());
}
// If deactivating this machine, remove the local activation token
let local_id = machine_id_internal()?;
if machine_id == local_id {
let act_path = activation_path(&app)?;
if act_path.exists() {
let _ = fs::remove_file(&act_path);
}
}
Ok(())
}
/// List all machines currently activated for the stored license.
#[tauri::command]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Ok(vec![]);
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/verify", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot verify license".to_string());
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
// The verify endpoint returns machines in the response when valid
let machines = body["machines"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(machines)
}
/// Check the local activation status without contacting the server.
#[tauri::command]
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
let machine_id = machine_id_internal()?;
let act_path = activation_path(&app)?;
let is_activated = if act_path.exists() {
if let Ok(token) = fs::read_to_string(&act_path) {
if let Ok(decoding_key) = embedded_decoding_key() {
validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok()
} else {
false
}
} else {
false
}
} else {
false
};
Ok(ActivationStatus {
is_activated,
machine_id,
})
}
// === Tests ====================================================================================
#[cfg(test)]

View file

@ -1,9 +1,11 @@
pub mod auth_commands;
pub mod entitlements;
pub mod export_import_commands;
pub mod fs_commands;
pub mod license_commands;
pub mod profile_commands;
pub use auth_commands::*;
pub use entitlements::*;
pub use export_import_commands::*;
pub use fs_commands::*;

View file

@ -1,6 +1,8 @@
mod commands;
mod database;
use std::sync::Mutex;
use tauri::{Emitter, Listener};
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -82,12 +84,41 @@ pub fn run() {
];
tauri::Builder::default()
.manage(commands::auth_commands::OAuthState {
code_verifier: Mutex::new(None),
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Listen for deep-link events (simpl-resultat://auth/callback?code=...)
let handle = app.handle().clone();
app.listen("deep-link://new-url", move |event| {
let payload = event.payload();
// payload is a JSON-serialized array of URL strings
if let Ok(urls) = serde_json::from_str::<Vec<String>>(payload) {
for url in urls {
if let Some(code) = extract_auth_code(&url) {
let h = handle.clone();
tauri::async_runtime::spawn(async move {
match commands::handle_auth_callback(h.clone(), code).await {
Ok(account) => {
let _ = h.emit("auth-callback-success", &account);
}
Err(err) => {
let _ = h.emit("auth-callback-error", &err);
}
}
});
}
}
}
});
Ok(())
})
.plugin(
@ -121,7 +152,34 @@ pub fn run() {
commands::get_edition,
commands::get_machine_id,
commands::check_entitlement,
commands::activate_machine,
commands::deactivate_machine,
commands::list_activated_machines,
commands::get_activation_status,
commands::start_oauth,
commands::handle_auth_callback,
commands::refresh_auth_token,
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// Extract the `code` query parameter from a deep-link callback URL.
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
fn extract_auth_code(url: &str) -> Option<String> {
let url = url.trim();
if !url.starts_with("simpl-resultat://auth/callback") {
return None;
}
let query = url.split('?').nth(1)?;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next()? == "code" {
return kv.next().map(|v| v.to_string());
}
}
None
}

View file

@ -18,7 +18,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
}
},
"bundle": {
@ -34,6 +34,11 @@
"createUpdaterArtifacts": true
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["simpl-resultat"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
"endpoints": [

View file

@ -0,0 +1,83 @@
import { useTranslation } from "react-i18next";
import { User, LogIn, LogOut, Loader2, AlertCircle } from "lucide-react";
import { useAuth } from "../../hooks/useAuth";
export default function AccountCard() {
const { t } = useTranslation();
const { state, login, logout } = useAuth();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<User size={18} />
{t("account.title")}
<span className="text-xs font-normal text-[var(--muted-foreground)]">
{t("account.optional")}
</span>
</h2>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{state.status === "authenticated" && state.account && (
<div className="space-y-3">
<div className="flex items-center gap-3">
{state.account.picture && (
<img
src={state.account.picture}
alt=""
className="w-10 h-10 rounded-full"
/>
)}
<div>
<p className="font-medium">
{state.account.name || state.account.email}
</p>
{state.account.name && (
<p className="text-sm text-[var(--muted-foreground)]">
{state.account.email}
</p>
)}
</div>
</div>
<button
type="button"
onClick={logout}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
<LogOut size={14} />
{t("account.signOut")}
</button>
</div>
)}
{(state.status === "unauthenticated" || state.status === "idle") && (
<div className="space-y-3">
<p className="text-sm text-[var(--muted-foreground)]">
{t("account.description")}
</p>
<button
type="button"
onClick={login}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<LogIn size={14} />
{t("account.signIn")}
</button>
</div>
)}
{state.status === "loading" && (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={14} className="animate-spin" />
{t("common.loading")}
</div>
)}
</div>
);
}

View file

@ -1,8 +1,16 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useLicense } from "../../hooks/useLicense";
import {
MachineInfo,
ActivationStatus,
activateMachine,
deactivateMachine,
listActivatedMachines,
getActivationStatus,
} from "../../services/licenseService";
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
@ -11,6 +19,75 @@ export default function LicenseCard() {
const { state, submitKey } = useLicense();
const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false);
const [machines, setMachines] = useState<MachineInfo[]>([]);
const [activation, setActivation] = useState<ActivationStatus | null>(null);
const [machineLoading, setMachineLoading] = useState(false);
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [machineError, setMachineError] = useState<string | null>(null);
const hasLicense = state.edition !== "free";
const loadActivation = useCallback(async () => {
if (!hasLicense) return;
try {
const status = await getActivationStatus();
setActivation(status);
} catch {
// Ignore — activation status is best-effort
}
}, [hasLicense]);
const loadMachines = useCallback(async () => {
setMachineLoading(true);
setMachineError(null);
try {
const list = await listActivatedMachines();
setMachines(list);
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
}, []);
useEffect(() => {
void loadActivation();
}, [loadActivation]);
const handleActivate = async () => {
setMachineLoading(true);
setMachineError(null);
try {
await activateMachine();
await loadActivation();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
};
const handleDeactivate = async (machineId: string) => {
setDeactivatingId(machineId);
try {
await deactivateMachine(machineId);
await loadActivation();
await loadMachines();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setDeactivatingId(null);
}
};
const toggleMachines = async () => {
const next = !showMachines;
setShowMachines(next);
if (next && machines.length === 0) {
await loadMachines();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -123,6 +200,100 @@ export default function LicenseCard() {
</div>
</form>
)}
{hasLicense && (
<div className="border-t border-[var(--border)] pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Monitor size={16} />
{t("license.machines.title")}
</h3>
<div className="flex items-center gap-2">
{activation && !activation.is_activated && (
<button
type="button"
onClick={handleActivate}
disabled={machineLoading}
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
>
{machineLoading && <Loader2 size={12} className="animate-spin" />}
{t("license.activate")}
</button>
)}
{activation?.is_activated && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<CheckCircle size={12} />
{t("license.machines.activated")}
</span>
)}
<button
type="button"
onClick={toggleMachines}
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
>
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{machineError && (
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<p>{machineError}</p>
</div>
)}
{showMachines && (
<div className="space-y-2">
{machineLoading && machines.length === 0 && (
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Loader2 size={12} className="animate-spin" />
{t("common.loading")}
</div>
)}
{!machineLoading && machines.length === 0 && (
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.machines.noMachines")}
</p>
)}
{machines.map((m) => {
const isThis = activation?.machine_id === m.machine_id;
return (
<div
key={m.machine_id}
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
>
<div>
<span className="font-medium">
{m.machine_name || m.machine_id.slice(0, 12)}
</span>
{isThis && (
<span className="ml-2 text-xs text-[var(--positive)]">
({t("license.machines.thisMachine")})
</span>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{new Date(m.activated_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => handleDeactivate(m.machine_id)}
disabled={deactivatingId === m.machine_id}
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{deactivatingId === m.machine_id && (
<Loader2 size={10} className="animate-spin" />
)}
{t("license.machines.deactivate")}
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

126
src/hooks/useAuth.ts Normal file
View file

@ -0,0 +1,126 @@
import { useCallback, useEffect, useReducer } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { listen } from "@tauri-apps/api/event";
import {
AccountInfo,
startOAuth,
getAccountInfo,
checkSubscriptionStatus,
logoutAccount,
} from "../services/authService";
type AuthStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error";
interface AuthState {
status: AuthStatus;
account: AccountInfo | null;
error: string | null;
}
type AuthAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; account: AccountInfo | null }
| { type: "LOGIN_START" }
| { type: "LOGOUT" }
| { type: "ERROR"; error: string };
const initialState: AuthState = {
status: "idle",
account: null,
error: null,
};
function reducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: action.account ? "authenticated" : "unauthenticated",
account: action.account,
error: null,
};
case "LOGIN_START":
return { ...state, status: "loading", error: null };
case "LOGOUT":
return { status: "unauthenticated", account: null, error: null };
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useAuth() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
// checkSubscriptionStatus refreshes the token if last check > 24h,
// otherwise returns cached account info. Graceful on network errors.
const account = await checkSubscriptionStatus();
dispatch({ type: "LOAD_DONE", account });
} catch (e) {
// Fallback to cached account info if the check command itself fails
try {
const cached = await getAccountInfo();
dispatch({ type: "LOAD_DONE", account: cached });
} catch {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}
}, []);
const login = useCallback(async () => {
dispatch({ type: "LOGIN_START" });
try {
const url = await startOAuth();
await openUrl(url);
// The actual auth completion happens via the deep-link callback,
// which triggers handle_auth_callback on the Rust side.
// The UI should call refresh() after the callback completes.
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const logout = useCallback(async () => {
try {
await logoutAccount();
dispatch({ type: "LOGOUT" });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Listen for deep-link auth callback events from the Rust backend
useEffect(() => {
const unlisten: Array<() => void> = [];
listen<AccountInfo>("auth-callback-success", (event) => {
dispatch({ type: "LOAD_DONE", account: event.payload });
}).then((fn) => unlisten.push(fn));
listen<string>("auth-callback-error", (event) => {
dispatch({ type: "ERROR", error: event.payload });
}).then((fn) => unlisten.push(fn));
return () => {
unlisten.forEach((fn) => fn());
};
}, []);
return { state, refresh, login, logout };
}

View file

@ -861,6 +861,27 @@
"free": "Free",
"base": "Base",
"premium": "Premium"
}
},
"removeLicense": "Remove license",
"machines": {
"title": "Machines",
"count": "{{count}}/{{limit}} machines activated",
"activating": "Activating...",
"activated": "Activated",
"notActivated": "Not activated",
"deactivate": "Deactivate",
"deactivating": "Deactivating...",
"activateError": "Activation failed",
"thisMachine": "This machine",
"noMachines": "No machines activated"
}
},
"account": {
"title": "Maximus Account",
"optional": "Optional",
"description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.",
"signIn": "Sign in",
"signOut": "Sign out",
"connected": "Connected"
}
}

View file

@ -861,6 +861,27 @@
"free": "Gratuite",
"base": "Base",
"premium": "Premium"
}
},
"removeLicense": "Supprimer la licence",
"machines": {
"title": "Machines",
"count": "{{count}}/{{limit}} machines activées",
"activating": "Activation...",
"activated": "Activée",
"notActivated": "Non activée",
"deactivate": "Désactiver",
"deactivating": "Désactivation...",
"activateError": "Échec de l'activation",
"thisMachine": "Cette machine",
"noMachines": "Aucune machine activée"
}
},
"account": {
"title": "Compte Maximus",
"optional": "Optionnel",
"description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.",
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"connected": "Connecté"
}
}

View file

@ -20,6 +20,7 @@ import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard";
import LicenseCard from "../components/settings/LicenseCard";
import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard";
export default function SettingsPage() {
@ -76,6 +77,9 @@ export default function SettingsPage() {
{/* License card */}
<LicenseCard />
{/* Account card */}
<AccountCard />
{/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4">

View file

@ -0,0 +1,32 @@
import { invoke } from "@tauri-apps/api/core";
export interface AccountInfo {
email: string;
name: string | null;
picture: string | null;
subscription_status: string | null;
}
export async function startOAuth(): Promise<string> {
return invoke<string>("start_oauth");
}
export async function handleAuthCallback(code: string): Promise<AccountInfo> {
return invoke<AccountInfo>("handle_auth_callback", { code });
}
export async function refreshAuthToken(): Promise<AccountInfo> {
return invoke<AccountInfo>("refresh_auth_token");
}
export async function getAccountInfo(): Promise<AccountInfo | null> {
return invoke<AccountInfo | null>("get_account_info");
}
export async function checkSubscriptionStatus(): Promise<AccountInfo | null> {
return invoke<AccountInfo | null>("check_subscription_status");
}
export async function logoutAccount(): Promise<void> {
return invoke<void>("logout");
}

View file

@ -34,3 +34,31 @@ export async function getMachineId(): Promise<string> {
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;
}
export async function activateMachine(): Promise<void> {
return invoke<void>("activate_machine");
}
export async function deactivateMachine(machineId: string): Promise<void> {
return invoke<void>("deactivate_machine", { machineId });
}
export async function listActivatedMachines(): Promise<MachineInfo[]> {
return invoke<MachineInfo[]>("list_activated_machines");
}
export async function getActivationStatus(): Promise<ActivationStatus> {
return invoke<ActivationStatus>("get_activation_status");
}