Skip to content

Commit 52480ce

Browse files
committed
Licensing: Continue the feature
- Add license key entry dialog - Wire it up in the top menu - Fix the readable code / encrypted code discrepancy, and generate a new short code. - Fix collecting organization name - Add collection of org address and tax ID - Add a key-value store in CloudFlare, - Create infra creator script to create the worker and the KV store in CloudFlare without clicking around on their UI - Update instructions to let the user find the license key UI where it actually is - Update tests
1 parent df6e1f3 commit 52480ce

20 files changed

Lines changed: 984 additions & 115 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"licensing/AboutWindow.svelte": { "reason": "UI component" },
2424
"licensing/CommercialReminderModal.svelte": { "reason": "UI component" },
2525
"licensing/ExpirationModal.svelte": { "reason": "UI component" },
26+
"licensing/LicenseKeyDialog.svelte": { "reason": "UI modal, depends on Tauri commands" },
2627
"network-store.svelte.ts": { "reason": "Depends on Tauri APIs" },
2728
"onboarding/FullDiskAccessPrompt.svelte": { "reason": "UI component" },
2829
"settings-store.ts": { "reason": "Depends on Tauri store APIs" },

apps/desktop/src-tauri/src/commands/licensing.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ pub fn get_window_title(app: tauri::AppHandle) -> String {
1515
licensing::get_window_title(&status)
1616
}
1717

18-
/// Activate a license key.
18+
/// Activate a license key or short code.
19+
/// If the input is a short code (CMDR-XXXX-XXXX-XXXX), it first exchanges it for the full key.
1920
#[tauri::command]
20-
pub fn activate_license(app: tauri::AppHandle, license_key: String) -> Result<licensing::LicenseInfo, String> {
21-
licensing::activate_license(&app, &license_key)
21+
pub async fn activate_license(app: tauri::AppHandle, license_key: String) -> Result<licensing::LicenseInfo, String> {
22+
licensing::activate_license_async(&app, &license_key).await
2223
}
2324

2425
/// Get information about the current license (if any).

apps/desktop/src-tauri/src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@ mod settings;
5555
mod volumes;
5656

5757
use menu::{
58-
ABOUT_ID, COMMAND_PALETTE_ID, GO_BACK_ID, GO_FORWARD_ID, GO_PARENT_ID, MenuState, SHOW_HIDDEN_FILES_ID,
59-
SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID,
60-
SORT_DESCENDING_ID, SWITCH_PANE_ID, VIEW_MODE_BRIEF_ID, VIEW_MODE_FULL_ID, ViewMode,
58+
ABOUT_ID, COMMAND_PALETTE_ID, ENTER_LICENSE_KEY_ID, GO_BACK_ID, GO_FORWARD_ID, GO_PARENT_ID, MenuState,
59+
SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID,
60+
SORT_BY_NAME_ID, SORT_BY_SIZE_ID, SORT_DESCENDING_ID, SWITCH_PANE_ID, VIEW_MODE_BRIEF_ID, VIEW_MODE_FULL_ID,
61+
ViewMode,
6162
};
6263
use tauri::{Emitter, Manager};
6364

@@ -200,6 +201,9 @@ pub fn run() {
200201
} else if id == ABOUT_ID {
201202
// Emit event to show our custom About window
202203
let _ = app.emit("show-about", ());
204+
} else if id == ENTER_LICENSE_KEY_ID {
205+
// Emit event to show the license key entry dialog
206+
let _ = app.emit("show-license-key-dialog", ());
203207
} else if id == COMMAND_PALETTE_ID {
204208
// Emit event to show the command palette
205209
let _ = app.emit("show-command-palette", ());

apps/desktop/src-tauri/src/licensing/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub use app_status::{
1111
AppStatus, LicenseType, get_app_status, get_window_title, mark_commercial_reminder_dismissed,
1212
mark_expiration_modal_shown, needs_validation, reset_license, update_cached_status, validate_license_async,
1313
};
14-
pub use verification::{LicenseInfo, activate_license, get_license_info};
14+
pub use verification::{LicenseInfo, activate_license, activate_license_async, get_license_info};
1515

1616
use serde::{Deserialize, Serialize};
1717

@@ -23,4 +23,6 @@ pub struct LicenseData {
2323
pub issued_at: String,
2424
#[serde(rename = "type")]
2525
pub license_type: Option<String>,
26+
#[serde(rename = "organizationName")]
27+
pub organization_name: Option<String>,
2628
}

apps/desktop/src-tauri/src/licensing/validation_client.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,79 @@ struct ValidationRequest {
3030
transaction_id: String,
3131
}
3232

33+
/// Response from the /activate endpoint.
34+
#[derive(Debug, Clone, Deserialize)]
35+
#[serde(rename_all = "camelCase")]
36+
pub struct ActivateResponse {
37+
pub license_key: Option<String>,
38+
/// Organization name from KV store (also embedded in license_key payload).
39+
#[allow(dead_code)]
40+
pub organization_name: Option<String>,
41+
pub error: Option<String>,
42+
}
43+
44+
/// Request body for the /activate endpoint.
45+
#[derive(Debug, Clone, Serialize)]
46+
struct ActivateRequest {
47+
code: String,
48+
}
49+
50+
/// Check if a string looks like a short license code (CMDR-XXXX-XXXX-XXXX).
51+
pub fn is_short_code(input: &str) -> bool {
52+
let trimmed = input.trim().to_uppercase();
53+
// Match CMDR-XXXX-XXXX-XXXX format
54+
if !trimmed.starts_with("CMDR-") {
55+
return false;
56+
}
57+
let parts: Vec<&str> = trimmed.split('-').collect();
58+
if parts.len() != 4 {
59+
return false;
60+
}
61+
// Check each segment after "CMDR" is 4 chars
62+
parts[1..]
63+
.iter()
64+
.all(|p| p.len() == 4 && p.chars().all(|c| c.is_ascii_alphanumeric()))
65+
}
66+
67+
/// Exchange a short license code for the full cryptographic key.
68+
///
69+
/// Returns the full key or an error message.
70+
pub async fn activate_short_code(code: &str) -> Result<String, String> {
71+
// In mock mode, return a mock key
72+
#[cfg(debug_assertions)]
73+
if std::env::var("CMDR_MOCK_LICENSE").is_ok() {
74+
return Err("Mock mode: short code activation not available".to_string());
75+
}
76+
77+
let client = reqwest::Client::builder()
78+
.timeout(std::time::Duration::from_secs(10))
79+
.build()
80+
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
81+
82+
let url = format!("{}/activate", LICENSE_SERVER_URL);
83+
84+
let response = client
85+
.post(&url)
86+
.json(&ActivateRequest {
87+
code: code.trim().to_uppercase(),
88+
})
89+
.send()
90+
.await
91+
.map_err(|e| format!("Failed to connect to license server: {}", e))?;
92+
93+
let status = response.status();
94+
let body: ActivateResponse = response
95+
.json()
96+
.await
97+
.map_err(|e| format!("Invalid response from license server: {}", e))?;
98+
99+
if !status.is_success() {
100+
return Err(body.error.unwrap_or_else(|| "License code not found".to_string()));
101+
}
102+
103+
body.license_key.ok_or_else(|| "No license key in response".to_string())
104+
}
105+
33106
/// Validate a license with the server.
34107
///
35108
/// Returns the validation response or None if the request failed.
@@ -108,4 +181,46 @@ mod tests {
108181
assert_eq!(response.organization_name, None);
109182
assert_eq!(response.expires_at, None);
110183
}
184+
185+
#[test]
186+
fn test_is_short_code_valid() {
187+
assert!(is_short_code("CMDR-ABCD-EFGH-1234"));
188+
assert!(is_short_code("cmdr-abcd-efgh-1234")); // Case insensitive
189+
assert!(is_short_code(" CMDR-ABCD-EFGH-1234 ")); // Whitespace trimmed
190+
assert!(is_short_code("CMDR-2345-6789-WXYZ"));
191+
}
192+
193+
#[test]
194+
fn test_is_short_code_invalid() {
195+
assert!(!is_short_code("ABCD-EFGH-IJKL-MNOP")); // No CMDR prefix
196+
assert!(!is_short_code("CMDR-ABC-EFGH-1234")); // Segment too short
197+
assert!(!is_short_code("CMDR-ABCDE-FGHI-1234")); // Segment too long
198+
assert!(!is_short_code("CMDR-ABCD-EFGH")); // Missing segment
199+
assert!(!is_short_code("something.signature")); // Full key format
200+
assert!(!is_short_code("")); // Empty
201+
assert!(!is_short_code("CMDR")); // Just prefix
202+
}
203+
204+
#[test]
205+
fn test_activate_response_success() {
206+
let json = r#"{
207+
"licenseKey": "eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ==.c2lnbmF0dXJl",
208+
"organizationName": "Acme Corp"
209+
}"#;
210+
211+
let response: ActivateResponse = serde_json::from_str(json).unwrap();
212+
assert!(response.license_key.is_some());
213+
assert!(response.error.is_none());
214+
}
215+
216+
#[test]
217+
fn test_activate_response_error() {
218+
let json = r#"{
219+
"error": "License code not found or expired"
220+
}"#;
221+
222+
let response: ActivateResponse = serde_json::from_str(json).unwrap();
223+
assert!(response.license_key.is_none());
224+
assert_eq!(response.error, Some("License code not found or expired".to_string()));
225+
}
111226
}

apps/desktop/src-tauri/src/licensing/verification.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! License key verification using Ed25519 signatures.
22
33
use crate::licensing::LicenseData;
4+
use crate::licensing::validation_client::{activate_short_code, is_short_code};
45
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
56
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
67
use serde::{Deserialize, Serialize};
@@ -21,6 +22,7 @@ pub struct LicenseInfo {
2122
pub email: String,
2223
pub transaction_id: String,
2324
pub issued_at: String,
25+
pub organization_name: Option<String>,
2426
}
2527

2628
/// Activate a license key. Returns the license info if valid.
@@ -39,9 +41,25 @@ pub fn activate_license(app: &tauri::AppHandle, license_key: &str) -> Result<Lic
3941
email: data.email,
4042
transaction_id: data.transaction_id,
4143
issued_at: data.issued_at,
44+
organization_name: data.organization_name,
4245
})
4346
}
4447

48+
/// Activate a license key or short code (async version).
49+
/// If the input is a short code (CMDR-XXXX-XXXX-XXXX), it first exchanges it for the full key.
50+
pub async fn activate_license_async(app: &tauri::AppHandle, input: &str) -> Result<LicenseInfo, String> {
51+
let full_key = if is_short_code(input) {
52+
// Exchange short code for full key via server
53+
activate_short_code(input).await?
54+
} else {
55+
// Already a full key
56+
input.to_string()
57+
};
58+
59+
// Now activate with the full key
60+
activate_license(app, &full_key)
61+
}
62+
4563
/// Get stored license info, if any.
4664
pub fn get_license_info(app: &tauri::AppHandle) -> Option<LicenseInfo> {
4765
let store = app.store("license.json").ok()?;
@@ -51,6 +69,7 @@ pub fn get_license_info(app: &tauri::AppHandle) -> Option<LicenseInfo> {
5169
email: data.email,
5270
transaction_id: data.transaction_id,
5371
issued_at: data.issued_at,
72+
organization_name: data.organization_name,
5473
})
5574
}
5675

@@ -188,6 +207,7 @@ mod tests {
188207
transaction_id: "txn_test_123".to_string(),
189208
issued_at: "2026-01-08T12:00:00Z".to_string(),
190209
license_type: None,
210+
organization_name: Some("Test Corp".to_string()),
191211
};
192212

193213
// Serialize payload (same as server)
@@ -210,6 +230,7 @@ mod tests {
210230
assert_eq!(data.email, "test@example.com");
211231
assert_eq!(data.transaction_id, "txn_test_123");
212232
assert_eq!(data.issued_at, "2026-01-08T12:00:00Z");
233+
assert_eq!(data.organization_name, Some("Test Corp".to_string()));
213234
}
214235

215236
/// Test that tampering with license key is detected
@@ -228,6 +249,7 @@ mod tests {
228249
transaction_id: "txn_original".to_string(),
229250
issued_at: "2026-01-08T12:00:00Z".to_string(),
230251
license_type: None,
252+
organization_name: Some("Original Corp".to_string()),
231253
};
232254
let original_json = serde_json::to_string(&original_data).unwrap();
233255
let signature = signing_key.sign(original_json.as_bytes());
@@ -239,6 +261,7 @@ mod tests {
239261
transaction_id: "txn_original".to_string(),
240262
issued_at: "2026-01-08T12:00:00Z".to_string(),
241263
license_type: None,
264+
organization_name: Some("Original Corp".to_string()),
242265
};
243266
let tampered_json = serde_json::to_string(&tampered_data).unwrap();
244267
let tampered_payload_base64 = BASE64.encode(tampered_json.as_bytes());
@@ -273,6 +296,7 @@ mod tests {
273296
transaction_id: "txn_test".to_string(),
274297
issued_at: "2026-01-08T12:00:00Z".to_string(),
275298
license_type: None,
299+
organization_name: None,
276300
};
277301
let payload_json = serde_json::to_string(&license_data).unwrap();
278302
let signature = signing_key.sign(payload_json.as_bytes());

apps/desktop/src-tauri/src/menu.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ pub enum ViewMode {
8383
/// Menu item ID for About window.
8484
pub const ABOUT_ID: &str = "about";
8585

86+
/// Menu item ID for Enter License Key.
87+
pub const ENTER_LICENSE_KEY_ID: &str = "enter_license_key";
88+
8689
/// Builds the application menu with default macOS items plus a custom View and File submenu enhancements.
8790
pub fn build_menu<R: Runtime>(
8891
app: &AppHandle<R>,
@@ -109,6 +112,16 @@ pub fn build_menu<R: Runtime>(
109112
if i == 0 {
110113
submenu.remove(pred)?;
111114
submenu.insert(&about_item, 0)?;
115+
116+
// Add "Enter License Key..." after About
117+
let enter_license_key_item = tauri::menu::MenuItem::with_id(
118+
app,
119+
ENTER_LICENSE_KEY_ID,
120+
"Enter license key...",
121+
true,
122+
None::<&str>,
123+
)?;
124+
submenu.insert(&enter_license_key_item, 1)?;
112125
break;
113126
}
114127
}

apps/desktop/src/lib/file-explorer/ShareBrowser.svelte

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,24 @@
239239
}
240240
}
241241
242+
function handleArrowKey(key: string): boolean {
243+
const lastIndex = sortedShares.length - 1
244+
const newIndex =
245+
key === 'ArrowDown'
246+
? Math.min(cursorIndex + 1, lastIndex)
247+
: key === 'ArrowUp'
248+
? Math.max(cursorIndex - 1, 0)
249+
: key === 'ArrowLeft'
250+
? 0
251+
: key === 'ArrowRight'
252+
? lastIndex
253+
: null
254+
if (newIndex === null) return false
255+
cursorIndex = newIndex
256+
scrollToIndex(cursorIndex)
257+
return true
258+
}
259+
242260
export function handleKeyDown(e: KeyboardEvent): boolean {
243261
if (showLoginForm) {
244262
// Login form handles its own keyboard events
@@ -265,39 +283,27 @@
265283
return true
266284
}
267285
268-
switch (e.key) {
269-
case 'ArrowDown':
270-
e.preventDefault()
271-
cursorIndex = Math.min(cursorIndex + 1, sortedShares.length - 1)
272-
scrollToIndex(cursorIndex)
273-
return true
274-
case 'ArrowUp':
275-
e.preventDefault()
276-
cursorIndex = Math.max(cursorIndex - 1, 0)
277-
scrollToIndex(cursorIndex)
278-
return true
279-
case 'ArrowLeft':
280-
e.preventDefault()
281-
cursorIndex = 0
282-
scrollToIndex(cursorIndex)
283-
return true
284-
case 'ArrowRight':
285-
e.preventDefault()
286-
cursorIndex = sortedShares.length - 1
287-
scrollToIndex(cursorIndex)
288-
return true
289-
case 'Enter':
290-
e.preventDefault()
291-
if (cursorIndex >= 0 && cursorIndex < sortedShares.length) {
292-
onShareSelect?.(sortedShares[cursorIndex], authenticatedCredentials)
293-
}
294-
return true
295-
case 'Escape':
296-
case 'Backspace':
297-
e.preventDefault()
298-
onBack?.()
299-
return true
286+
// Handle arrow keys
287+
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
288+
e.preventDefault()
289+
return handleArrowKey(e.key)
300290
}
291+
292+
// Handle action keys
293+
if (e.key === 'Enter') {
294+
e.preventDefault()
295+
if (cursorIndex >= 0 && cursorIndex < sortedShares.length) {
296+
onShareSelect?.(sortedShares[cursorIndex], authenticatedCredentials)
297+
}
298+
return true
299+
}
300+
301+
if (e.key === 'Escape' || e.key === 'Backspace') {
302+
e.preventDefault()
303+
onBack?.()
304+
return true
305+
}
306+
301307
return false
302308
}
303309

0 commit comments

Comments
 (0)