Skip to content

Commit e69c2ac

Browse files
committed
feat: add promotions infra
1 parent 89fe45f commit e69c2ac

File tree

6 files changed

+294
-4
lines changed

6 files changed

+294
-4
lines changed

src/phoenix/trust_ring.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,15 @@ function _selectKeys() {
179179
}
180180

181181
const CRED_KEY_API = "API_KEY";
182+
const CRED_KEY_ENTITLEMENTS = "ENTITLEMENTS_GRANT_KEY";
182183
const { key, iv } = _selectKeys();
183184
// this key is set at boot time as a truct base for all the core components before any extensions are loaded.
184185
// just before extensions are loaded, this key is blanked. This can be used by core modules to talk with other
185186
// core modules securely without worrying about interception by extensions.
186187
// KernalModeTrust should only be available within all code that loads before the first default/any extension.
187188
window.KernalModeTrust = {
188189
CRED_KEY_API,
190+
CRED_KEY_ENTITLEMENTS,
189191
aesKeys: { key, iv },
190192
setCredential,
191193
getCredential,

src/services/login-service.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
*/
2525

2626
define(function (require, exports, module) {
27+
const Promotions = require("./promotions");
2728

2829
const KernalModeTrust = window.KernalModeTrust;
2930
if(!KernalModeTrust){

src/services/profile-menu.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,16 +194,14 @@ define(function (require, exports, module) {
194194
// Use kernal mode apis for trusted check of pro features.
195195
Phoenix.pro.plan = {
196196
paidSubscriber: false,
197-
name: "Community Edition",
198-
isInTrial: false
197+
name: "Community Edition"
199198
};
200199
}
201200

202201
if (entitlements && entitlements.plan){
203202
Phoenix.pro.plan = {
204203
paidSubscriber: entitlements.plan.paidSubscriber,
205204
name: entitlements.plan.name,
206-
isInTrial: entitlements.plan.isInTrial,
207205
validTill: entitlements.plan.validTill
208206
};
209207
}

src/services/promotions.js

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it under
7+
* the terms of the GNU Affero General Public License as published by the Free
8+
* Software Foundation, either version 3 of the License, or (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
11+
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
16+
*
17+
*/
18+
19+
/*global logger*/
20+
21+
/**
22+
* Promotions Service
23+
*
24+
* Manages pro trial promotions for both native and browser applications.
25+
* Provides loginless pro trials
26+
*
27+
* - First install: 30-day trial on first usage
28+
* - Subsequent versions: 3-day trial (or remaining from 30-day if still valid)
29+
* - Older versions: No new trial, but existing 30-day trial remains valid
30+
*/
31+
32+
define(function (require, exports, module) {
33+
34+
const EventDispatcher = require("utils/EventDispatcher"),
35+
Metrics = require("utils/Metrics"),
36+
semver = require("thirdparty/semver.browser");
37+
38+
const KernalModeTrust = window.KernalModeTrust;
39+
if (!KernalModeTrust) {
40+
throw new Error("Promotions service requires access to KernalModeTrust. Cannot boot without trust ring");
41+
}
42+
43+
// Make this module an event dispatcher
44+
EventDispatcher.makeEventDispatcher(exports);
45+
46+
// Constants
47+
const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install";
48+
const TRIAL_POLL_MS = 10 * 1000; // 10 seconds after start, we assign a free trial if possible
49+
const FIRST_INSTALL_TRIAL_DAYS = 30;
50+
const SUBSEQUENT_TRIAL_DAYS = 3;
51+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
52+
53+
/**
54+
* Generate SHA-256 signature for trial data integrity
55+
*/
56+
async function _generateSignature(proVersion, endDate) {
57+
const salt = window.AppConfig ? window.AppConfig.version : "default-salt";
58+
const data = proVersion + "|" + endDate + "|" + salt;
59+
60+
// Use browser crypto API for SHA-256 hashing
61+
const encoder = new TextEncoder();
62+
const dataBuffer = encoder.encode(data);
63+
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
64+
const hashArray = Array.from(new Uint8Array(hashBuffer));
65+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // hash hex string
66+
}
67+
68+
/**
69+
* Validate trial data signature
70+
*/
71+
async function _isValidSignature(trialData) {
72+
if (!trialData.signature || !trialData.proVersion || !trialData.endDate) {
73+
return false;
74+
}
75+
76+
const expectedSignature = await _generateSignature(trialData.proVersion, trialData.endDate);
77+
return trialData.signature === expectedSignature;
78+
}
79+
80+
/**
81+
* Get stored trial data with validation
82+
*/
83+
async function _getTrialData() {
84+
try {
85+
if (Phoenix.isNativeApp) {
86+
// Native app: use KernalModeTrust credential store
87+
const data = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS);
88+
if (!data) {
89+
return null;
90+
}
91+
const parsed = JSON.parse(data);
92+
return (await _isValidSignature(parsed)) ? parsed : null;
93+
} else {
94+
// Browser app: use virtual filesystem
95+
return new Promise((resolve) => {
96+
// app support dir in browser is /fs/app/
97+
const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json";
98+
window.fs.readFile(filePath, 'utf8', function (err, data) {
99+
if (err || !data) {
100+
resolve(null);
101+
return;
102+
}
103+
try {
104+
const parsed = JSON.parse(data);
105+
_isValidSignature(parsed).then(isValid => {
106+
resolve(isValid ? parsed : null);
107+
}).catch(() => resolve(null));
108+
} catch (e) {
109+
resolve(null);
110+
}
111+
});
112+
});
113+
}
114+
} catch (error) {
115+
console.error("Error getting trial data:", error);
116+
return null;
117+
}
118+
}
119+
120+
/**
121+
* Store trial data with signature
122+
*/
123+
async function _setTrialData(trialData) {
124+
trialData.signature = await _generateSignature(trialData.proVersion, trialData.endDate);
125+
126+
try {
127+
if (Phoenix.isNativeApp) {
128+
// Native app: use KernalModeTrust credential store
129+
await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_ENTITLEMENTS, JSON.stringify(trialData));
130+
} else {
131+
// Browser app: use virtual filesystem
132+
return new Promise((resolve, reject) => {
133+
const filePath = Phoenix.app.getApplicationSupportDirectory() + "entitlements_granted.json";
134+
window.fs.writeFile(filePath, JSON.stringify(trialData), 'utf8', (writeErr) => {
135+
if (writeErr) {
136+
console.error("Error storing trial data:", writeErr);
137+
reject(writeErr);
138+
} else {
139+
resolve();
140+
}
141+
});
142+
});
143+
}
144+
} catch (error) {
145+
console.error("Error setting trial data:", error);
146+
throw error;
147+
}
148+
}
149+
150+
/**
151+
* Calculate remaining trial days from end date
152+
*/
153+
function _calculateRemainingTrialDays(existingTrialData) {
154+
const now = Date.now();
155+
const trialEndDate = existingTrialData.endDate;
156+
157+
// Calculate days remaining until trial ends
158+
const msRemaining = trialEndDate - now;
159+
return Math.max(0, Math.ceil(msRemaining / MS_PER_DAY)); // days remaining
160+
}
161+
162+
/**
163+
* Check if version1 is newer than version2 using semver
164+
*/
165+
function _isNewerVersion(version1, version2) {
166+
try {
167+
return semver.gt(version1, version2);
168+
} catch (error) {
169+
console.error("Error comparing versions:", error, version1, version2);
170+
// Assume not newer if comparison fails
171+
return false;
172+
}
173+
}
174+
175+
/**
176+
* Check if pro trial is currently activated
177+
*/
178+
async function isProTrialActivated() {
179+
const trialData = await _getTrialData();
180+
if (!trialData) {
181+
return false;
182+
}
183+
184+
const remainingDays = _calculateRemainingTrialDays(trialData);
185+
186+
return remainingDays > 0;
187+
}
188+
189+
async function activateProTrial() {
190+
const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0";
191+
const existingTrialData = await _getTrialData();
192+
193+
let trialDays = FIRST_INSTALL_TRIAL_DAYS;
194+
let endDate;
195+
const now = Date.now();
196+
let metricString = `${currentVersion.replaceAll(".", "_")}`; // 3.1.0 -> 3_1_0
197+
198+
if (existingTrialData) {
199+
// Existing trial found
200+
const remainingDays = _calculateRemainingTrialDays(existingTrialData);
201+
const trialVersion = existingTrialData.proVersion;
202+
const isNewerVersion = _isNewerVersion(currentVersion, trialVersion);
203+
204+
// Check if we should grant any trial
205+
if (remainingDays <= 0 && !isNewerVersion) {
206+
console.log("Existing trial expired, same/older version - no new trial");
207+
return;
208+
}
209+
210+
// Determine trial days and end date
211+
if (isNewerVersion) {
212+
if (remainingDays >= SUBSEQUENT_TRIAL_DAYS) {
213+
// Newer version but existing trial is longer - keep existing
214+
console.log(`Newer version, keeping existing trial (${remainingDays} days)`);
215+
trialDays = remainingDays;
216+
endDate = existingTrialData.endDate;
217+
metricString = `nD_${metricString}_upgrade`;
218+
} else {
219+
// Newer version with shorter existing trial - give 3 days
220+
console.log(`Newer version - granting ${SUBSEQUENT_TRIAL_DAYS} days trial`);
221+
trialDays = SUBSEQUENT_TRIAL_DAYS;
222+
endDate = now + (trialDays * MS_PER_DAY);
223+
metricString = `3D_${metricString}`;
224+
}
225+
} else {
226+
// Same/older version: keep existing trial - no changes needed
227+
console.log(`Same/older version - keeping existing ${remainingDays} day trial.`);
228+
return;
229+
}
230+
} else {
231+
// First install - 30 days from now
232+
endDate = now + (FIRST_INSTALL_TRIAL_DAYS * MS_PER_DAY);
233+
metricString = `1Mo_${metricString}`;
234+
}
235+
236+
const trialData = {
237+
proVersion: currentVersion,
238+
endDate: endDate
239+
};
240+
241+
await _setTrialData(trialData);
242+
Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trialAct", metricString);
243+
Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", "activated");
244+
console.log(`Pro trial activated for ${trialDays} days`);
245+
246+
// Trigger the event for UI to handle
247+
exports.trigger(EVENT_PRO_UPGRADE_ON_INSTALL, {
248+
trialDays: trialDays,
249+
isFirstInstall: !existingTrialData
250+
});
251+
}
252+
253+
function _isAnyDialogsVisible() {
254+
const $modal = $(`.modal.instance`);
255+
return $modal.length > 0 && $modal.is(':visible');
256+
}
257+
258+
/**
259+
* Start the pro trial activation process
260+
* Waits 2 minutes, then triggers the upgrade event
261+
*/
262+
console.log(`Checking pro trial activation in ${TRIAL_POLL_MS / 1000} seconds...`);
263+
264+
const trialActivatePoller = setInterval(()=> {
265+
if(_isAnyDialogsVisible()){
266+
// maybe the user hasn't dismissed the new project dialog
267+
return;
268+
}
269+
clearInterval(trialActivatePoller);
270+
activateProTrial().catch(error => {
271+
Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", `errActivate`);
272+
logger.reportError(error, "Error activating pro trial:");
273+
});
274+
}, TRIAL_POLL_MS);
275+
276+
// Public exports
277+
exports.isProTrialActivated = isProTrialActivated;
278+
exports.EVENT_PRO_UPGRADE_ON_INSTALL = EVENT_PRO_UPGRADE_ON_INSTALL;
279+
280+
});

src/utils/Metrics.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ define(function (require, exports, module) {
120120
NODEJS: "node",
121121
LINT: "lint",
122122
GIT: "git",
123-
AUTH: "auth"
123+
AUTH: "auth",
124+
PRO: "pro"
124125
};
125126

126127
/**

test/spec/SpecRunnerUtils.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,10 @@ define(function (require, exports, module) {
554554
await awaitsForDone(promise);
555555
}
556556

557+
/**
558+
* Waits for any modal dialogs to appear, waits for default for 2 seconds
559+
* note: this is a test-only/short task function and shouldn't be used in production as this polls
560+
*/
557561
async function waitForModalDialog(timeout=2000) {
558562
// Make sure there's one and only one dialog open
559563
await awaitsFor(()=>{
@@ -562,6 +566,10 @@ define(function (require, exports, module) {
562566
}, timeout);
563567
}
564568

569+
/**
570+
* Waits for any modal dialogs to close, waits for default for 2 seconds
571+
* note: this is a test-only/short task function and shouldn't be used in production as this polls
572+
*/
565573
async function waitForNoModalDialog(timeout=2000) {
566574
// Make sure there's one and only one dialog open
567575
await awaitsFor(()=>{

0 commit comments

Comments
 (0)