Skip to content

Commit 39cf7b4

Browse files
committed
Licensing: better integrate across the app
- Add "Get a license" link in the license key dialog for users without a key - Make command palette label dynamic: "Enter license key" vs. "See license details" - About window: show "Get a license" (unlicensed) vs. "Upgrade" (supporters) - Add License section to Settings with status display and action buttons - License section is searchable by license-related keywords
1 parent 7740fbc commit 39cf7b4

8 files changed

Lines changed: 246 additions & 8 deletions

File tree

apps/desktop/src/lib/commands/command-registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,11 @@ export const commands: Command[] = [
460460
export function getPaletteCommands(): Command[] {
461461
return commands.filter((c) => c.showInPalette)
462462
}
463+
464+
/** Update the license command name based on whether a license exists. Keeps the command palette in sync with the native menu label. */
465+
export function updateLicenseCommandName(hasExistingLicense: boolean): void {
466+
const cmd = commands.find((c) => c.id === 'app.licenseKey')
467+
if (cmd) {
468+
cmd.name = hasExistingLicense ? 'See license details' : 'Enter license key'
469+
}
470+
}

apps/desktop/src/lib/licensing/AboutWindow.svelte

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,22 @@
6161
}
6262
}
6363
64-
// Determine if we should show the Upgrade link
65-
function shouldShowUpgradeLink(): boolean {
66-
if (!status) return true // No license - show upgrade
64+
// Determine if we should show the license purchase/upgrade link
65+
function shouldShowLicenseLink(): boolean {
66+
if (!status) return true
6767
if (status.type === 'personal') return true
6868
if (status.type === 'expired') return true
69-
// Supporter and commercial don't show the generic upgrade link
69+
// Supporter shows "upgrade" to commercial
70+
if (status.type === 'supporter') return true
7071
return false
7172
}
7273
74+
// Label varies by license state: "Get a license" for unlicensed, "Upgrade" for supporters
75+
function getLicenseLinkLabel(): string {
76+
if (status?.type === 'supporter') return 'Upgrade'
77+
return 'Get a license'
78+
}
79+
7380
// Determine if we should show the commercial upgrade prompt (for supporters)
7481
function shouldShowCommercialPrompt(): boolean {
7582
return status?.type === 'supporter'
@@ -124,10 +131,10 @@
124131

125132
<div class="links">
126133
<a href="https://getcmdr.com" onclick={handleLinkClick('https://getcmdr.com')}>Website</a>
127-
{#if shouldShowUpgradeLink()}
134+
{#if shouldShowLicenseLink()}
128135
<span class="separator">•</span>
129136
<a href="https://getcmdr.com/pricing" onclick={handleLinkClick('https://getcmdr.com/pricing')}
130-
>Upgrade</a
137+
>{getLicenseLinkLabel()}</a
131138
>
132139
{/if}
133140
<span class="separator">•</span>

apps/desktop/src/lib/licensing/LicenseKeyDialog.svelte

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,16 @@
387387
<Button variant="primary" onclick={handleResetConfirm}>Continue</Button>
388388
</div>
389389
{:else if !isLoading}
390-
<p class="description">Paste your license key from the email you received after purchase.</p>
390+
<p class="description">
391+
Paste your license key from the email you received after purchase. Don't have one yet? <a
392+
href="https://getcmdr.com/pricing"
393+
class="buy-link"
394+
onclick={(event: MouseEvent) => {
395+
event.preventDefault()
396+
void openExternalUrl('https://getcmdr.com/pricing')
397+
}}>Get a license</a
398+
>.
399+
</p>
391400

392401
<div class="input-group">
393402
<input
@@ -600,6 +609,15 @@
600609
color: var(--color-accent-hover);
601610
}
602611
612+
.buy-link {
613+
color: var(--color-accent);
614+
text-decoration: underline;
615+
}
616+
617+
.buy-link:hover {
618+
color: var(--color-accent-hover);
619+
}
620+
603621
.button-row {
604622
display: flex;
605623
gap: var(--spacing-md);

apps/desktop/src/lib/settings/components/SettingsContent.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import McpServerSection from '$lib/settings/sections/McpServerSection.svelte'
1111
import LoggingSection from '$lib/settings/sections/LoggingSection.svelte'
1212
import AdvancedSection from '$lib/settings/sections/AdvancedSection.svelte'
13+
import LicenseSection from '$lib/settings/sections/LicenseSection.svelte'
1314
import SectionSummary from './SectionSummary.svelte'
1415
import { getMatchingSettingIdsInSection } from '$lib/settings/settings-search'
1516
import { searchCommands } from '$lib/commands/fuzzy-search'
@@ -127,6 +128,12 @@
127128
</section>
128129
{/if}
129130

131+
{#if shouldShowSection(['License']) || (isTopLevelSection && selectedSection[0] === 'License')}
132+
<section data-section-id="license">
133+
<LicenseSection />
134+
</section>
135+
{/if}
136+
130137
<!-- Developer sections -->
131138
{#if shouldShowSection(['Developer', 'MCP server'])}
132139
<section data-section-id="developer-mcp-server">

apps/desktop/src/lib/settings/components/SettingsSidebar.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
// Note: Themes is in the registry, so we don't add it here
2121
const specialSections = [
2222
{ name: 'Keyboard shortcuts', path: ['Keyboard shortcuts'] },
23+
{ name: 'License', path: ['License'] },
2324
{ name: 'Advanced', path: ['Advanced'] },
2425
]
2526
@@ -94,6 +95,10 @@
9495
if (path[0] === 'Keyboard shortcuts') {
9596
return matchingSections.has('Keyboard shortcuts')
9697
}
98+
// License: show if query matches license-related terms
99+
if (path[0] === 'License') {
100+
return matchingSections.has('License')
101+
}
97102
return false
98103
}
99104
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte'
3+
import { emitTo } from '@tauri-apps/api/event'
4+
import { getCurrentWindow } from '@tauri-apps/api/window'
5+
import SettingsSection from '../components/SettingsSection.svelte'
6+
import {
7+
openExternalUrl,
8+
getLicenseInfo,
9+
getLicenseStatus,
10+
type LicenseInfo,
11+
type LicenseStatus,
12+
} from '$lib/tauri-commands'
13+
import Button from '$lib/ui/Button.svelte'
14+
15+
let licenseInfo = $state<LicenseInfo | null>(null)
16+
let licenseStatus = $state<LicenseStatus | null>(null)
17+
let isLoading = $state(true)
18+
19+
onMount(async () => {
20+
try {
21+
const [info, status] = await Promise.all([
22+
getLicenseInfo().catch(() => null),
23+
getLicenseStatus().catch(() => null),
24+
])
25+
licenseInfo = info
26+
licenseStatus = status
27+
} finally {
28+
isLoading = false
29+
}
30+
})
31+
32+
function getLicenseTypeLabel(): string {
33+
if (!licenseInfo) return 'Personal (free)'
34+
if (licenseInfo.licenseType === 'commercial_perpetual') return 'Commercial perpetual'
35+
if (licenseInfo.licenseType === 'commercial_subscription') return 'Commercial subscription'
36+
if (licenseInfo.licenseType === 'supporter') return 'Supporter'
37+
return 'Personal (free)'
38+
}
39+
40+
function formatDate(dateStr: string | null | undefined): string {
41+
if (!dateStr) return ''
42+
try {
43+
return new Date(dateStr).toLocaleDateString(undefined, {
44+
year: 'numeric',
45+
month: 'long',
46+
day: 'numeric',
47+
})
48+
} catch {
49+
return dateStr
50+
}
51+
}
52+
53+
function getStatusText(): string | null {
54+
if (!licenseStatus) return null
55+
if (licenseStatus.type === 'expired') return `Expired on ${formatDate(licenseStatus.expiredAt)}`
56+
if (licenseStatus.type === 'commercial') {
57+
if (licenseStatus.licenseType === 'commercial_perpetual') {
58+
return licenseStatus.expiresAt ? `Updates until ${formatDate(licenseStatus.expiresAt)}` : 'Active'
59+
}
60+
return licenseStatus.expiresAt ? `Valid until ${formatDate(licenseStatus.expiresAt)}` : 'Active'
61+
}
62+
return null
63+
}
64+
65+
const hasLicense = $derived(licenseInfo !== null)
66+
const statusText = $derived(getStatusText())
67+
68+
async function handleManageLicense() {
69+
await emitTo('main', 'execute-command', { commandId: 'app.licenseKey' })
70+
await getCurrentWindow().close()
71+
}
72+
73+
async function handleBuyLicense() {
74+
await openExternalUrl('https://getcmdr.com/pricing')
75+
}
76+
</script>
77+
78+
<SettingsSection title="License">
79+
{#if isLoading}
80+
<p class="loading-text">Loading...</p>
81+
{:else}
82+
<div class="license-info">
83+
<div class="info-row">
84+
<span class="info-label">License type</span>
85+
<span class="info-value">{getLicenseTypeLabel()}</span>
86+
</div>
87+
{#if licenseInfo?.organizationName}
88+
<div class="info-row">
89+
<span class="info-label">Organization</span>
90+
<span class="info-value">{licenseInfo.organizationName}</span>
91+
</div>
92+
{/if}
93+
{#if statusText}
94+
<div class="info-row">
95+
<span class="info-label">Status</span>
96+
<span
97+
class="info-value"
98+
class:status-expired={licenseStatus?.type === 'expired'}
99+
class:status-active={licenseStatus?.type === 'commercial' ||
100+
licenseStatus?.type === 'supporter'}>{statusText}</span
101+
>
102+
</div>
103+
{/if}
104+
{#if licenseInfo?.shortCode}
105+
<div class="info-row">
106+
<span class="info-label">License key</span>
107+
<span class="info-value mono">{licenseInfo.shortCode}</span>
108+
</div>
109+
{/if}
110+
</div>
111+
112+
<div class="actions">
113+
{#if hasLicense}
114+
<Button variant="secondary" onclick={handleManageLicense}>Manage license key</Button>
115+
{:else}
116+
<Button variant="secondary" onclick={handleManageLicense}>Enter license key</Button>
117+
<Button variant="secondary" onclick={handleBuyLicense}>Get a license</Button>
118+
{/if}
119+
</div>
120+
{/if}
121+
</SettingsSection>
122+
123+
<style>
124+
.loading-text {
125+
color: var(--color-text-tertiary);
126+
font-size: var(--font-size-sm);
127+
margin: 0;
128+
}
129+
130+
.license-info {
131+
background: var(--color-bg-tertiary);
132+
border-radius: var(--radius-lg);
133+
padding: var(--spacing-xs) var(--spacing-lg);
134+
margin-bottom: var(--spacing-lg);
135+
}
136+
137+
.info-row {
138+
display: flex;
139+
justify-content: space-between;
140+
align-items: center;
141+
gap: var(--spacing-xl);
142+
padding: var(--spacing-sm) 0;
143+
}
144+
145+
.info-row:not(:last-child) {
146+
border-bottom: 1px solid var(--color-border-subtle);
147+
}
148+
149+
.info-label {
150+
font-size: var(--font-size-sm);
151+
color: var(--color-text-secondary);
152+
flex-shrink: 0;
153+
}
154+
155+
.info-value {
156+
font-size: var(--font-size-sm);
157+
color: var(--color-text-primary);
158+
font-weight: 500;
159+
text-align: right;
160+
}
161+
162+
.info-value.mono {
163+
font-family: var(--font-mono);
164+
letter-spacing: 0.02em;
165+
}
166+
167+
.info-value.status-expired {
168+
color: var(--color-error);
169+
}
170+
171+
.info-value.status-active {
172+
color: var(--color-toast-success-stripe);
173+
}
174+
175+
.actions {
176+
display: flex;
177+
gap: var(--spacing-md);
178+
}
179+
</style>

apps/desktop/src/lib/settings/settings-search.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ export function getMatchingSections(query: string): Set<string> {
200200
}
201201
}
202202

203+
// Check if query matches license-related terms
204+
if (query.trim()) {
205+
const licenseKeywords = 'license key activation commercial supporter personal upgrade buy purchase pricing'
206+
const lowerQuery = query.toLowerCase()
207+
if (licenseKeywords.includes(lowerQuery) || 'license'.includes(lowerQuery)) {
208+
sections.add('License')
209+
}
210+
}
211+
203212
return sections
204213
}
205214

apps/desktop/src/routes/(main)/+page.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
loadLicenseStatus,
4242
triggerValidationIfNeeded,
4343
} from '$lib/licensing/licensing-store.svelte'
44+
import { updateLicenseCommandName } from '$lib/commands/command-registry'
4445
import type { ViewMode } from '$lib/app-status-store'
4546
4647
// Interface for DualPaneExplorer's exported methods
@@ -491,6 +492,9 @@
491492
showCommercialReminder = true
492493
}
493494
495+
// Update command palette label to match native menu
496+
updateLicenseCommandName(licenseStatus.type !== 'personal')
497+
494498
// Load window title based on license status
495499
windowTitle = await getWindowTitle()
496500
} catch {
@@ -604,7 +608,8 @@
604608
605609
async function handleLicenseKeySuccess() {
606610
showLicenseKeyDialog = false
607-
// Refresh the window title to reflect new license status
611+
// Refresh the window title and command palette label to reflect new license status
612+
updateLicenseCommandName(true)
608613
windowTitle = await getWindowTitle()
609614
// Show the About window so user can see their license status
610615
showAboutWindow = true

0 commit comments

Comments
 (0)