diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index 01c1fe59ca..ebbe6bc829 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -740,6 +740,12 @@ Opens support resources ## HELP\_GET\_PRO Opens Phoenix Pro page +**Kind**: global variable + + +## HELP\_MANAGE\_LICENSES +Manage Pro licenses + **Kind**: global variable @@ -914,6 +920,12 @@ Hides the sidebar ## SHOW\_SIDEBAR Shows the sidebar +**Kind**: global variable + + +## REINSTALL\_CREDS +Reinstalls credentials in keychain + **Kind**: global variable diff --git a/docs/API-Reference/utils/NodeUtils.md b/docs/API-Reference/utils/NodeUtils.md index 36a415cd6c..e8a03dbd0e 100644 --- a/docs/API-Reference/utils/NodeUtils.md +++ b/docs/API-Reference/utils/NodeUtils.md @@ -107,3 +107,46 @@ Opens a file in the default application for its type on Windows, macOS, and Linu | --- | --- | --- | | fullPath | string | The path to the file/folder to open. | + + +## getDeviceID() ⇒ Promise.<(string\|null)> +gets the os device id. this usually won't change till os reinstall. + +**Kind**: global function +**Returns**: Promise.<(string\|null)> - - Resolves with the os identifier or null +**Throws**: + +- Error - If called from the browser + + + +## addDeviceLicenseSystemWide() ⇒ Promise.<boolean> +Enables device license by creating a system-wide license file. +On Windows, macOS, and Linux this will request elevation if needed. + +**Kind**: global function +**Returns**: Promise.<boolean> - - Resolves true if system wide defile file added, else false. +**Throws**: + +- Error - If called from the browser + + + +## removeDeviceLicenseSystemWide() ⇒ Promise.<boolean> +Removes the system-wide device license file. +On Windows, macOS, and Linux this will request elevation if needed. + +**Kind**: global function +**Returns**: Promise.<boolean> - - Resolves true if system wide defile file removed, else false. +**Throws**: + +- Error - If called from the browser + + + +## isLicensedDeviceSystemWide() ⇒ Promise.<boolean> +Checks if the current machine is configured to check for system-wide device license for all users at app start. +This validates that the system-wide license file exists, contains valid JSON, and has `licensedDevice: true`. + +**Kind**: global function +**Returns**: Promise.<boolean> - - Resolves with `true` if the device is licensed, `false` otherwise. diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 2a5b79a17a..8bbf6cbb76 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -9,6 +9,7 @@ "version": "4.1.2-0", "license": "GNU-AGPL3.0", "dependencies": { + "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^3.0.1", "cross-spawn": "^7.0.6", "lmdb": "^2.9.2", @@ -22,6 +23,11 @@ "node": "20" } }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==" + }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-2.9.2.tgz", diff --git a/src-node/package.json b/src-node/package.json index 196d54e228..3aa819148f 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -26,6 +26,7 @@ "lmdb": "^2.9.2", "mime-types": "^2.1.35", "cross-spawn": "^7.0.6", - "which": "^2.0.1" + "which": "^2.0.1", + "@expo/sudo-prompt": "^9.3.2" } -} \ No newline at end of file +} diff --git a/src-node/utils.js b/src-node/utils.js index 5e0ab484bb..7167d60356 100644 --- a/src-node/utils.js +++ b/src-node/utils.js @@ -4,9 +4,13 @@ const fs = require('fs'); const fsPromise = require('fs').promises; const path = require('path'); const os = require('os'); +const sudo = require('@expo/sudo-prompt'); const {lintFile} = require("./ESLint/service"); let openModule, open; // dynamic import when needed +const options = { name: 'Phoenix Code' }; +const licenseFileContent = JSON.stringify({}); + async function _importOpen() { if(open){ return open; @@ -269,6 +273,176 @@ async function getEnvironmentVariable(varName) { return process.env[varName]; } +function getLicensePath() { + switch (os.platform()) { + case 'win32': + return 'C:\\Program Files\\Phoenix Code Control\\device-license'; + case 'darwin': + return '/Library/Application Support/phoenix-code-control/device-license'; + case 'linux': + return '/etc/phoenix-code-control/device-license'; + default: + throw new Error(`Unsupported platform: ${os.platform()}`); + } +} + +function sudoExec(command) { + return new Promise((resolve, reject) => { + sudo.exec(command, options, (error, stdout, stderr) => { + if (error) { + return reject(error); + } + resolve({ stdout, stderr }); + }); + }); +} + +function readFileUtf8(p) { + return new Promise((resolve, reject) => { + fs.readFile(p, 'utf8', (err, data) => (err ? reject(err) : resolve(data))); + }); +} + +/** + * Writes the license file in a world-readable location. + * Works on Windows, macOS, and Linux. + */ +async function addDeviceLicense() { + const targetPath = getLicensePath(); + let command; + // we should not store any sensitive information in this file as this is world readable. we use the + // device id itself as license key for that machine. the device id is not associated with any cloud credits + // and all entitlements are local to device only for this threat model to work. So stolen device IDs doesn't + // have any meaning. + + if (os.platform() === 'win32') { + // Windows: write file and explicitly grant Everyone read rights + const dir = 'C:\\Program Files\\Phoenix Code Control'; + command = + `powershell -Command "` + + `New-Item -ItemType Directory -Force '${dir}' | Out-Null; ` + + `Set-Content -Path '${targetPath}' -Value '${licenseFileContent}' -Encoding UTF8; ` + + `icacls '${targetPath}' /inheritance:e /grant *S-1-1-0:RX | Out-Null"`; + } else { + // macOS / Linux: mkdir + write + chmod 0644 (world-readable, owner-writable) + const dir = path.dirname(targetPath); + command = + `/bin/mkdir -p "${dir}"` + + ` && printf '%s' '${licenseFileContent}' > "${targetPath}"` + + ` && /bin/chmod 0644 "${targetPath}"`; + } + + await sudoExec(command); + return targetPath; +} + +async function removeDeviceLicense() { + const targetPath = getLicensePath(); + let command; + + if (os.platform() === 'win32') { + command = `powershell -Command "if (Test-Path '${targetPath}') { Remove-Item -Path '${targetPath}' -Force }"`; + } else { + command = `/bin/rm -f "${targetPath}"`; + } + + await sudoExec(command); + return targetPath; +} + +async function isLicensedDevice() { + const targetPath = getLicensePath(); + try { + const data = await readFileUtf8(targetPath); + JSON.parse(data.trim()); + return true; // currently, the existence of the file itself is flag. in future, we may choose to add more. + } catch { + // file missing, unreadable, or invalid JSON + return false; + } +} + +async function _getLinuxDeviceID() { + const data = await fsPromise.readFile("/etc/machine-id", "utf8"); + const id = data.trim(); + return id || null; + // throw on error to main. + // no fallback, /var/lib/dbus/machine-id may need sudo in some machines +} + +/** + * Get the macOS device ID (IOPlatformUUID). + * @returns {Promise} + */ +function _getMacDeviceID() { + // to read this in mac bash, do: + // #!/bin/bash + // device_id=$(ioreg -rd1 -c IOPlatformExpertDevice | awk -F\" '/IOPlatformUUID/ {print $4}' | tr -d '[:space:]') + // echo "$device_id" + return new Promise((resolve, reject) => { + exec( + 'ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', + { encoding: 'utf8' }, + (err, stdout) => { + if (err) { + console.error('Failed to get Mac device ID:', err.message); + return reject(err); + } + + const match = stdout.match(/"IOPlatformUUID" = "([^"]+)"/); + if (match && match[1]) { + resolve(match[1]); + } else { + resolve(null); + } + } + ); + }); +} + +/** + * Get the Windows device ID (MachineGuid). + * @returns {Promise} + * + * In a Windows batch file, you can get this with: + * reg query HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography /v MachineGuid + */ +function _getWindowsDeviceID() { + return new Promise((resolve, reject) => { + exec( + 'reg query HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid', + { encoding: 'utf8' }, + (err, stdout) => { + if (err) { + console.error('Failed to get Windows device ID:', err.message); + return reject(err); + } + + // Example output: + // HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography + // MachineGuid REG_SZ 4c4c4544-0034-5a10-8051-cac04f305a31 + const match = stdout.match(/MachineGuid\s+REG_[A-Z]+\s+([a-fA-F0-9-]+)/); + if (match && match[1]) { + resolve(match[1].trim()); + } else { + resolve(null); + } + } + ); + }); +} + +async function getDeviceID() { + if (process.platform === "linux") { + return _getLinuxDeviceID(); + } else if (process.platform === "darwin") { + return _getMacDeviceID(); + } else if (process.platform === "win32") { + return _getWindowsDeviceID(); + } + throw new Error(`Unsupported platform: ${process.platform}`); +} + exports.getURLContent = getURLContent; exports.setLocaleStrings = setLocaleStrings; exports.getPhoenixBinaryVersion = getPhoenixBinaryVersion; @@ -278,5 +452,9 @@ exports.getEnvironmentVariable = getEnvironmentVariable; exports.ESLintFile = ESLintFile; exports.openNativeTerminal = openNativeTerminal; exports.openInDefaultApp = openInDefaultApp; +exports.addDeviceLicense = addDeviceLicense; +exports.removeDeviceLicense = removeDeviceLicense; +exports.isLicensedDevice = isLicensedDevice; +exports.getDeviceID = getDeviceID; exports._loadNodeExtensionModule = _loadNodeExtensionModule; exports._npmInstallInFolder = _npmInstallInFolder; diff --git a/src/command/Commands.js b/src/command/Commands.js index 391f3e1d46..dd9d5b0e64 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -408,6 +408,9 @@ define(function (require, exports, module) { /** Opens Phoenix Pro page */ exports.HELP_GET_PRO = "help.getPro"; // HelpCommandHandlers.js _handleLinkMenuItem() + /** Manage Pro licenses */ + exports.HELP_MANAGE_LICENSES = "help.manageLicenses"; // HelpCommandHandlers.js _handleLinkMenuItem() + /** Opens feature suggestion page */ exports.HELP_SUGGEST = "help.suggest"; // HelpCommandHandlers.js _handleLinkMenuItem() @@ -501,6 +504,9 @@ define(function (require, exports, module) { /** Shows the sidebar */ exports.SHOW_SIDEBAR = "view.showSidebar"; // SidebarView.js show() + /** Reinstalls credentials in keychain */ + exports.REINSTALL_CREDS = "debug.reinstallCreds"; // login-service.js handleReinstallCreds() + // commands /** Initializes a new git repository */ exports.CMD_GIT_INIT = "git-init"; diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index e047b9886d..8fec28a26e 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -269,6 +269,9 @@ define(function (require, exports, module) { menu.addMenuItem(Commands.HELP_SUPPORT); menu.addMenuDivider(); menu.addMenuItem(Commands.HELP_GET_PRO); + if(Phoenix.isNativeApp) { + menu.addMenuItem(Commands.HELP_MANAGE_LICENSES); + } menu.addMenuDivider(); if (brackets.config.suggest_feature_url) { menu.addMenuItem(Commands.HELP_SUGGEST); diff --git a/src/extensions/default/DebugCommands/main.js b/src/extensions/default/DebugCommands/main.js index e49f80fe26..e2a5b97824 100644 --- a/src/extensions/default/DebugCommands/main.js +++ b/src/extensions/default/DebugCommands/main.js @@ -820,6 +820,10 @@ define(function (require, exports, module) { } // this command is defined in core, but exposed only in Debug menu for now debugMenu.addMenuItem(Commands.FILE_OPEN_KEYMAP, null); + // Reinstall credentials menu item (native apps only) + if(Phoenix.isNativeApp) { + debugMenu.addMenuItem(Commands.REINSTALL_CREDS, null); + } const diagnosticsSubmenu = debugMenu.addSubMenu(Strings.CMD_DIAGNOSTIC_TOOLS, DIAGNOSTICS_SUBMENU); diagnosticsSubmenu.addMenuItem(DEBUG_RUN_UNIT_TESTS); CommandManager.register(Strings.CMD_BUILD_TESTS, DEBUG_BUILD_TESTS, TestBuilder.toggleTestBuilder); diff --git a/src/help/HelpCommandHandlers.js b/src/help/HelpCommandHandlers.js index 9fc47cc703..3286f8abb5 100644 --- a/src/help/HelpCommandHandlers.js +++ b/src/help/HelpCommandHandlers.js @@ -30,7 +30,8 @@ define(function (require, exports, module) { FileUtils = require("file/FileUtils"), NativeApp = require("utils/NativeApp"), Strings = require("strings"), - StringUtils = require("utils/StringUtils"), + StringUtils = require("utils/StringUtils"), + ManageLicenses = require("services/manage-licenses"), AboutDialogTemplate = require("text!htmlContent/about-dialog.html"), ContributorsTemplate = require("text!htmlContent/contributors-list.html"), Mustache = require("thirdparty/mustache/mustache"); @@ -168,6 +169,7 @@ define(function (require, exports, module) { CommandManager.register(Strings.CMD_GET_PRO, Commands.HELP_GET_PRO, _handleLinkMenuItem(brackets.config.purchase_url), { htmlName: getProString }); + CommandManager.register(Strings.CMD_MANAGE_LICENSES, Commands.HELP_MANAGE_LICENSES, ManageLicenses.showManageLicensesDialog); CommandManager.register(Strings.CMD_SUGGEST, Commands.HELP_SUGGEST, _handleLinkMenuItem(brackets.config.suggest_feature_url)); CommandManager.register(Strings.CMD_REPORT_ISSUE, Commands.HELP_REPORT_ISSUE, _handleLinkMenuItem(brackets.config.report_issue_url)); CommandManager.register(Strings.CMD_RELEASE_NOTES, Commands.HELP_RELEASE_NOTES, _handleLinkMenuItem(brackets.config.release_notes_url)); diff --git a/src/loggerSetup.js b/src/loggerSetup.js index 9e2469c52a..243314b35c 100644 --- a/src/loggerSetup.js +++ b/src/loggerSetup.js @@ -68,6 +68,8 @@ Bugsnag.notify(message? new CustomBugSnagError(message, error) :error); + } else { + console.error(message, error, error.nodeStack); } }, /** @@ -79,6 +81,8 @@ reportErrorMessage: function (message) { if(isBugsnagEnabled) { Bugsnag.notify(new CustomBugSnagError(message)); + } else { + console.error(message); } }, diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 3910de0ffe..0c148a9cdb 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -655,6 +655,7 @@ define({ "CMD_HOW_TO_USE_BRACKETS": "How to Use {APP_NAME}", "CMD_SUPPORT": "{APP_NAME} Support", "CMD_GET_PRO": "Get Phoenix Pro", + "CMD_MANAGE_LICENSES": "Manage Licenses", "CMD_USER_PROFILE": "{APP_NAME} Account", "CMD_DOCS": "Help, Getting Started", "CMD_SUGGEST": "Suggest a Feature", @@ -1690,5 +1691,28 @@ define({ "PROMO_PRO_UNLOCK_MESSAGE": "Subscribe now to unlock these advanced features:", "PROMO_PRO_TRIAL_DAYS_LEFT": "Phoenix Pro Trial ({0} days left)", "GET_PHOENIX_PRO": "Get Phoenix Pro", - "USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE": "Community Edition" + "USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE": "Community Edition", + // license dialogs + "MANAGE_LICENSE_DIALOG_TITLE": "Manage Device License", + "LICENSE_KEY": "License Key", + "LICENSE_KEY_ACTIVATE": "Activate License", + "LICENSE_KEY_ACTIVATING": "Activating\u2026", + "LICENSE_KEY_CURRENT": "Current Device License", + "LICENSE_KEY_CHECKING": "Checking license status\u2026", + "LICENSE_KEY_NONE": "No active device license found", + "LICENSE_STATUS_ACTIVE": "Active", + "LICENSE_INFO_STATUS_LABEL": "Status:", + "LICENSE_INFO_LICENSED_TO_LABEL": "Licensed to:", + "LICENSE_INFO_TYPE_LABEL": "License type:", + "LICENSE_INFO_VALID_UNTIL_LABEL": "Valid until:", + "LICENSE_CHECK_ERROR": "Error checking license status", + "LICENSE_STATUS_UNKNOWN": "Unknown", + "LICENSE_VALID_NEVER": "Never", + "LICENSE_STATUS_ERROR_CHECK": "Error checking license status", + "LICENSE_ACTIVATE_SUCCESS": "License activated system-wide. Please restart {APP_NAME} for the changes to take effect.", + "LICENSE_ACTIVATE_SUCCESS_PARTIAL": "License activated for your account only (not system-wide). Please restart {APP_NAME} for the changes to take effect.", + "LICENSE_ACTIVATE_FAIL": "Failed to activate license", + "LICENSE_ACTIVATE_FAIL_APPLY": "'Failed to apply license to device'", + "LICENSE_ENTER_KEY": "Please enter a license key", + "LICENSE_REAPPLY_TO_DEVICE": "Already activated? Reapply system-wide" }); diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js index ed9d04e14c..bfcd37368a 100644 --- a/src/phoenix/trust_ring.js +++ b/src/phoenix/trust_ring.js @@ -181,6 +181,7 @@ function _selectKeys() { const CRED_KEY_API = Phoenix.isTestWindow ? "API_KEY_TEST" : "API_KEY"; const CRED_KEY_PROMO = Phoenix.isTestWindow ? "PROMO_GRANT_KEY_TEST" : "PROMO_GRANT_KEY"; const SIGNATURE_SALT_KEY = Phoenix.isTestWindow ? "SIGNATURE_SALT_KEY_TEST" : "SIGNATURE_SALT_KEY"; +const VERSION_PORTER_KEY = Phoenix.isTestWindow ? "VERSION_PORTER_TEST" : "VERSION_PORTER"; const { key, iv } = _selectKeys(); async function setCredential(credKey, secret) { @@ -243,17 +244,91 @@ export async function initTrustRing() { // this will only work once in a window unless dismantleKeyring is called. So this is safe as // a public export as essentially this is a fn that only works in the boot and shutdown phase. await window.__TAURI__.tauri.invoke("trust_window_aes_key", {key, iv}); + + _portCredentials(); +} +async function reinstallCreds() { + if(!window.__TAURI__){ + throw new Error("reinstallCreds can only be called in tauri shell!"); + } + // Read current credential values + const apiKey = await getCredential(CRED_KEY_API); + const promoKey = await getCredential(CRED_KEY_PROMO); + const saltKey = await getCredential(SIGNATURE_SALT_KEY); + + // Remove credentials from keychain + if(apiKey) { + await removeCredential(CRED_KEY_API); + } + if(promoKey) { + await removeCredential(CRED_KEY_PROMO); + } + if(saltKey) { + await removeCredential(SIGNATURE_SALT_KEY); + } + + // Re-set credentials to refresh keychain access + if(apiKey) { + await setCredential(CRED_KEY_API, apiKey); + } + if(promoKey) { + await setCredential(CRED_KEY_PROMO, promoKey); + } + if(saltKey) { + await setCredential(SIGNATURE_SALT_KEY, saltKey); + } + + const currentVersion = Phoenix.metadata.version; + await setCredential(VERSION_PORTER_KEY, currentVersion); +} + +/** + * Handles keychain credential portability across app versions on macOS. + * not a problem in windows/linux. + * + * On macOS, the system keychain ties stored credentials to the app’s code signature. + * If the signature changes (for example: running a debug build, unsigned dev build, + * or re-signed binary), macOS will repeatedly prompt the user for their password + * every time credentials are accessed. This does not usually happen in official + * signed release builds, but it can be disruptive during development. + * + * To reduce this annoyance, we track the app version in the keychain. If the + * stored version and the current version don’t match, we reinstall credentials + * under the new signature so that future keychain access works without constant + * prompts. + */ +async function _portCredentials() { + if(!Phoenix.isNativeApp || Phoenix.platform === "win" || Phoenix.platform === "linux") { + return; + } + try { + const storedVersion = await getCredential(VERSION_PORTER_KEY); + const currentVersion = Phoenix.metadata.version; + + if (!storedVersion && currentVersion) { + // First boot or version key doesn't exist, set it + await setCredential(VERSION_PORTER_KEY, currentVersion); + } else if (storedVersion && currentVersion && storedVersion !== currentVersion) { + // Version changed, reinstall credentials + console.log(`Version changed from ${storedVersion} to ${currentVersion}, reinstalling credentials`); + // Update stored version first to prevent races with multi phoenix windows + await setCredential(VERSION_PORTER_KEY, currentVersion); + await reinstallCreds(); + } + } catch (error) { + console.error("Error during version-based credential check:", error); + } } /** * Generates an SHA-256 hash signature of the provided data string combined with a salt. * * @param {string} dataString - The input data string that needs to be signed. - * @param {string} salt - A salt value to combine with the data string for additional uniqueness. + * @param {string} [salt] - A Optional salt value to combine with the data string for additional uniqueness. * @return {Promise} A promise that resolves to the generated SHA-256 hash signature as a hexadecimal string. */ async function generateDataSignature(dataString, salt) { - const signatureData = dataString + "|" + salt; + const signatureData = salt ? dataString + "|" + salt : dataString; const encoder = new TextEncoder(); const dataBuffer = encoder.encode(signatureData); const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); @@ -293,7 +368,8 @@ window.KernalModeTrust = { generateRandomKeyAndIV, dismantleKeyring, generateDataSignature, - validateDataSignature + validateDataSignature, + reinstallCreds }; if(Phoenix.isSpecRunnerWindow){ window.specRunnerTestKernalModeTrust = window.KernalModeTrust; diff --git a/src/services/entitlements.js b/src/services/EntitlementsManager.js similarity index 89% rename from src/services/entitlements.js rename to src/services/EntitlementsManager.js index 6324b76efa..45781afa1f 100644 --- a/src/services/entitlements.js +++ b/src/services/EntitlementsManager.js @@ -39,8 +39,10 @@ define(function (require, exports, module) { let LoginService; // Create secure exports and set up event dispatcher - const Entitlements = {}; - EventDispatcher.makeEventDispatcher(Entitlements); + const EntitlementsManager = {}; + EventDispatcher.makeEventDispatcher(EntitlementsManager); + // Set up KernalModeTrust.Entitlements + KernalModeTrust.EntitlementsManager = EntitlementsManager; // Event constants const EVENT_ENTITLEMENTS_CHANGED = "entitlements_changed"; @@ -142,7 +144,7 @@ define(function (require, exports, module) { * @returns {Promise} [entitlement.validTill] - Timestamp when entitlement expires (if from server) * * @example - * const liveEditEntitlement = await Entitlements.getLiveEditEntitlement(); + * const liveEditEntitlement = await EntitlementsManager.getLiveEditEntitlement(); * if (liveEditEntitlement.activated) { * // Enable live edit feature * enableLiveEditFeature(); @@ -166,9 +168,6 @@ define(function (require, exports, module) { }; } - // Set up KernalModeTrust.Entitlements - KernalModeTrust.Entitlements = Entitlements; - let inited = false; function init() { if(inited){ @@ -179,14 +178,14 @@ define(function (require, exports, module) { // Set up event forwarding from LoginService LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, function() { effectiveEntitlements = null; - Entitlements.trigger(EVENT_ENTITLEMENTS_CHANGED); + EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); }); } // Test-only exports for integration testing if (Phoenix.isTestWindow) { window._test_entitlements_exports = { - EntitlementsService: Entitlements, + EntitlementsService: EntitlementsManager, isLoggedIn, getPlanDetails, isInProTrial, @@ -201,12 +200,12 @@ define(function (require, exports, module) { // no public exports to prevent extension tampering // Add functions to secure exports. These can be accessed via `KernalModeTrust.Entitlements.*` - Entitlements.isLoggedIn = isLoggedIn; - Entitlements.loginToAccount = loginToAccount; - Entitlements.getPlanDetails = getPlanDetails; - Entitlements.isInProTrial = isInProTrial; - Entitlements.getTrialRemainingDays = getTrialRemainingDays; - Entitlements.getRawEntitlements = getRawEntitlements; - Entitlements.getLiveEditEntitlement = getLiveEditEntitlement; - Entitlements.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; + EntitlementsManager.isLoggedIn = isLoggedIn; + EntitlementsManager.loginToAccount = loginToAccount; + EntitlementsManager.getPlanDetails = getPlanDetails; + EntitlementsManager.isInProTrial = isInProTrial; + EntitlementsManager.getTrialRemainingDays = getTrialRemainingDays; + EntitlementsManager.getRawEntitlements = getRawEntitlements; + EntitlementsManager.getLiveEditEntitlement = getLiveEditEntitlement; + EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; }); diff --git a/src/services/html/license-management.html b/src/services/html/license-management.html new file mode 100644 index 0000000000..7d98afbfd9 --- /dev/null +++ b/src/services/html/license-management.html @@ -0,0 +1,96 @@ + diff --git a/src/services/login-service.js b/src/services/login-service.js index ae3404e882..ca5e5c7ea7 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -16,7 +16,7 @@ * */ -/*global path*/ +/*global path, logger*/ /** * Shared Login Service @@ -29,11 +29,18 @@ define(function (require, exports, module) { require("./setup-login-service"); // this adds loginService to KernalModeTrust require("./promotions"); require("./login-utils"); - const EntitlementsDirectImport = require("./entitlements"); // this adds Entitlements to KernalModeTrust + const NodeUtils = require("utils/NodeUtils"), + PreferencesManager = require("preferences/PreferencesManager"), + Commands = require("command/Commands"), + CommandManager = require("command/CommandManager"); + const EntitlementsDirectImport = require("./EntitlementsManager"); // this adds Entitlements to KernalModeTrust const Metrics = require("utils/Metrics"), Strings = require("strings"); + const PREF_STATE_LICENSED_DEVICE_CHECK = "LICENSED_DEVICE_CHECK"; + PreferencesManager.stateManager.definePreference(PREF_STATE_LICENSED_DEVICE_CHECK, "boolean", false); + const MS_IN_DAY = 10 * 24 * 60 * 60 * 1000; const TEN_MINUTES = 10 * 60 * 1000; const FREE_PLAN_VALIDITY_DAYS = 10000; @@ -211,6 +218,34 @@ define(function (require, exports, module) { } } + let deviceIDCached = undefined; + async function getDeviceID() { + if(!Phoenix.isNativeApp) { + // We only grant device licenses to desktop apps. Browsers cannot be uniquely device identified obviously. + return null; + } + if(deviceIDCached !== undefined) { + return deviceIDCached; + } + try { + const deviceID = await NodeUtils.getDeviceID(); + if(!deviceID) { + deviceIDCached = null; + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "deviceID", "nullErr"); + return null; + } + deviceIDCached = KernalModeTrust.generateDataSignature(deviceID); + } catch (e) { + logger.reportError(e, "failed to sign deviceID"); + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "deviceID", "SignErr"); + deviceIDCached = null; + } + return deviceIDCached; + } + + + let deviceLicensePrimed = false, + licencedDeviceCredsAvailable = false; /** * Get entitlements from API or disc cache. @@ -219,8 +254,14 @@ define(function (require, exports, module) { * Returns null if the user is not logged in */ async function getEntitlements(forceRefresh = false) { + if(!deviceLicensePrimed) { + deviceLicensePrimed = true; + // we cache this as device license is only checked at app start. As invoves some files in system loactions, + // we dont want file access errors to happen on every entitlement check. + licencedDeviceCredsAvailable = await isLicensedDevice(); + } // Return null if not logged in - if (!LoginService.isLoggedIn()) { + if (!LoginService.isLoggedIn() && !licencedDeviceCredsAvailable) { return null; } @@ -254,7 +295,10 @@ define(function (require, exports, module) { const language = brackets.getLocale(); const currentVersion = window.AppConfig.apiVersion || "1.0.0"; let url = `${accountBaseURL}/getAppEntitlements?lang=${language}&version=${currentVersion}`+ - `&platform=${Phoenix.platform}&appType=${Phoenix.isNativeApp ? "desktop" : "browser"}}`; + `&platform=${Phoenix.platform}&appType=${Phoenix.isNativeApp ? "desktop" : "browser"}`; + if(licencedDeviceCredsAvailable) { + url += `&deviceID=${await getDeviceID()}`; + } let fetchOptions = { method: 'GET', headers: { @@ -268,7 +312,7 @@ define(function (require, exports, module) { const profile = LoginService.getProfile(); if (profile && profile.apiKey && profile.validationCode) { url += `&appSessionID=${encodeURIComponent(profile.apiKey)}&validationCode=${encodeURIComponent(profile.validationCode)}`; - } else { + } else if(!licencedDeviceCredsAvailable){ console.error('Missing appSessionID or validationCode for desktop app entitlements'); return null; } @@ -347,6 +391,8 @@ define(function (require, exports, module) { cachedEntitlements = null; _debounceEntitlementsChanged(); } + // Reset device license state so it's re-evaluated on next entitlement check + deviceLicensePrimed = false; } @@ -440,7 +486,7 @@ define(function (require, exports, module) { /** * Get effective entitlements for determining feature availability. - * This is for internal use only. All consumers in phoenix code should use `KernalModeTrust.Entitlements` APIs. + * This is for internal use only. All consumers in phoenix should use `KernalModeTrust.EntitlementsManager` APIs. * * @returns {Promise} Entitlements object or null if not logged in and no trial active * @@ -543,15 +589,16 @@ define(function (require, exports, module) { return serverEntitlements; } - // User has active trial + // now we need to grant trial, as user is entitled to trial if he is here. + // User has active server plan(either with login or device license) if (serverEntitlements && serverEntitlements.plan) { - // Logged-in user with trial if (serverEntitlements.plan.paidSubscriber) { - // Already a paid subscriber, return as-is + // Already a paid subscriber(or has device license), return as-is + // never inject trail data in this case. return serverEntitlements; } // Enhance entitlements for trial user - // ie if any entitlement has valid till expired, we need to deactivate that entitlement + // user in not a paid subscriber(nor he has device license), inject trial return { ...serverEntitlements, plan: { @@ -575,7 +622,7 @@ define(function (require, exports, module) { }; } - // Non-logged-in user with trial - return synthetic entitlements + // Non-logged-in, non licensed user with trial - return synthetic entitlements return { plan: { paidSubscriber: true, @@ -596,13 +643,67 @@ define(function (require, exports, module) { }; } + async function addDeviceLicense() { + deviceLicensePrimed = false; + PreferencesManager.stateManager.set(PREF_STATE_LICENSED_DEVICE_CHECK, true); + return NodeUtils.addDeviceLicenseSystemWide(); + } + + async function removeDeviceLicense() { + deviceLicensePrimed = false; + PreferencesManager.stateManager.set(PREF_STATE_LICENSED_DEVICE_CHECK, false); + return NodeUtils.removeDeviceLicenseSystemWide(); + } + + async function isLicensedDeviceSystemWide() { + return NodeUtils.isLicensedDeviceSystemWide(); + } + + let _isLicensedDeviceFlagForTest = false; + + /** + * Checks if app is configured to check for device licenses at app start at system or user level. + * + * @returns {Promise} - Resolves with `true` if the device is licensed, `false` otherwise. + */ + async function isLicensedDevice() { + if(Phoenix.isTestWindow) { + return _isLicensedDeviceFlagForTest; + } + if(!Phoenix.isNativeApp) { + // browser app doesn't support device licence keys, obviously. + return false; + } + const userCheck = PreferencesManager.stateManager.get(PREF_STATE_LICENSED_DEVICE_CHECK); + const systemCheck = await isLicensedDeviceSystemWide(); + return userCheck || systemCheck; + } + // Add functions to secure exports LoginService.getEntitlements = getEntitlements; LoginService.getEffectiveEntitlements = getEffectiveEntitlements; LoginService.clearEntitlements = clearEntitlements; LoginService.getSalt = getSalt; + LoginService.addDeviceLicense = addDeviceLicense; + LoginService.removeDeviceLicense = removeDeviceLicense; + LoginService.isLicensedDevice = isLicensedDevice; + LoginService.isLicensedDeviceSystemWide = isLicensedDeviceSystemWide; + LoginService.getDeviceID = getDeviceID; LoginService.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; + async function handleReinstallCreds() { + if(!Phoenix.isNativeApp) { + throw new Error("Reinstall credentials is only available in native apps"); + } + try { + await KernalModeTrust.reinstallCreds(); + console.log("Credentials reinstalled successfully"); + } catch (error) { + console.error("Error reinstalling credentials:", error); + throw error; + } + } + let inited = false; function init() { if(inited){ @@ -610,15 +711,23 @@ define(function (require, exports, module) { } inited = true; EntitlementsDirectImport.init(); + + // Register reinstall credentials command for native apps only + if(Phoenix.isNativeApp) { + CommandManager.register("Reinstall Credentials", Commands.REINSTALL_CREDS, handleReinstallCreds); + } } // Test-only exports for integration testing if (Phoenix.isTestWindow) { window._test_login_service_exports = { LoginService, - setFetchFn: function _setFetchFn(fn) { + setIsLicensedDevice: function (_isLicensedDevice) { + _isLicensedDeviceFlagForTest = _isLicensedDevice; + }, + setFetchFn: function (fn) { fetchFn = fn; }, - setDateNowFn: function _setDdateNowFn(fn) { + setDateNowFn: function (fn) { dateNowFn = fn; }, _validateAndFilterEntitlements: _validateAndFilterEntitlements diff --git a/src/services/manage-licenses.js b/src/services/manage-licenses.js new file mode 100644 index 0000000000..9d8e4cec86 --- /dev/null +++ b/src/services/manage-licenses.js @@ -0,0 +1,328 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global logger*/ + +/** + * Shared Login Service + * + * This module contains shared login service functionality used by both + * browser and desktop login implementations, including entitlements management. + */ + +define(function (require, exports, module) { + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + // integrated extensions will have access to kernal mode, but not external extensions + throw new Error("manage-licenses should have access to KernalModeTrust. Cannot boot without trust ring"); + } + + const Strings = require("strings"), + Dialogs = require("widgets/Dialogs"), + Mustache = require("thirdparty/mustache/mustache"), + licenseManagementHTML = require("text!./html/license-management.html"); + + // Save a copy of window.fetch so that extensions won't tamper with it + let fetchFn = window.fetch; + + /** + * Get the API base URL for license operations + */ + function _getAPIBaseURL() { + return Phoenix.config.account_url.replace(/\/$/, ''); // Remove trailing slash + } + + /** + * Call the validateDeviceLicense API + */ + async function _validateDeviceLicense(deviceLicenseKey) { + const apiURL = `${_getAPIBaseURL()}/validateDeviceLicense`; + + try { + const response = await fetchFn(apiURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + deviceLicenseKey: deviceLicenseKey + }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error('Error validating device license:', error); + throw error; + } + } + + /** + * Call the registerDevice API to activate a license + */ + async function _registerDevice(licenseKey, deviceLicenseKey, platform, deviceLabel) { + const apiURL = `${_getAPIBaseURL()}/registerDevice`; + + try { + const response = await fetchFn(apiURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + licenseKey: licenseKey, + deviceLicenseKey: deviceLicenseKey, + platform: platform, + deviceLabel: deviceLabel + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.errorMessage || `HTTP ${response.status}: ${response.statusText}`); + } + + return result; + } catch (error) { + console.error('Error registering device:', error); + throw error; + } + } + + /** + * Format date for display + */ + function _formatDate(timestamp) { + if (!timestamp) { + return Strings.LICENSE_VALID_NEVER; + } + const date = new Date(timestamp); + return date.toLocaleDateString(Phoenix.getLocale(), { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + /** + * Update the license status display in the dialog + */ + async function _updateLicenseStatusDisplay($dialog, licenseData) { + const $loading = $dialog.find('#license-status-loading'); + const $none = $dialog.find('#license-status-none'); + const $valid = $dialog.find('#license-status-valid'); + const $error = $dialog.find('#license-status-error'); + const $reapplyContainer = $dialog.find('#reapply-license-container'); + + // Hide all status sections + $loading.hide(); + $none.hide(); + $valid.hide(); + $error.hide(); + $reapplyContainer.hide(); + + if (licenseData && licenseData.isValid) { + // Show valid license info + $dialog.find('#licensed-to-name').text(licenseData.licensedToName || Strings.LICENSE_STATUS_UNKNOWN); + $dialog.find('#license-type-name').text(licenseData.licenseTypeName || Strings.LICENSE_STATUS_UNKNOWN); + $dialog.find('#license-valid-till').text(_formatDate(licenseData.validTill)); + $valid.show(); + + // Show reapply button if license is valid but not applied system-wide + const isLicensed = await KernalModeTrust.loginService.isLicensedDeviceSystemWide(); + if (!isLicensed) { + $reapplyContainer.show(); + } + } else if (licenseData && licenseData.isValid === false) { + // No valid license + $none.show(); + } else { + // Error state + $dialog.find('#license-error-message').text(Strings.LICENSE_STATUS_ERROR_CHECK); + $error.show(); + } + } + + /** + * Show activation result message + */ + function _showActivationMessage($dialog, isSuccess, message) { + const $messageDiv = $dialog.find('#activation-message'); + const $messageText = $dialog.find('#activation-message-text'); + + $messageText.text(message); + + // Remove previous classes + $messageDiv.removeClass('success error'); + + // Add appropriate class + if (isSuccess) { + $messageDiv.addClass('success'); + } else { + $messageDiv.addClass('error'); + } + + $messageDiv.show(); + + // Hide message after 5 seconds + setTimeout(() => { + $messageDiv.fadeOut(); + }, 5000); + } + + /** + * Load and display current license status + */ + async function _loadLicenseStatus($dialog) { + try { + const deviceID = await KernalModeTrust.loginService.getDeviceID(); + if (!deviceID) { + _updateLicenseStatusDisplay($dialog, { isValid: false }); + return; + } + + const licenseData = await _validateDeviceLicense(deviceID); + _updateLicenseStatusDisplay($dialog, licenseData); + } catch (error) { + console.error('Error loading license status:', error); + _updateLicenseStatusDisplay($dialog, null); + } + } + + /** + * Handle license activation + */ + async function _handleLicenseActivation($dialog, licenseKey) { + const $btn = $dialog.find('#activate-license-btn'); + const $btnText = $btn.find('.btn-text'); + const $btnSpinner = $btn.find('.btn-spinner'); + + try { + // Show loading state + $btn.prop('disabled', true); + $btnText.hide(); + $btnSpinner.show(); + + const deviceID = await KernalModeTrust.loginService.getDeviceID(); + if (!deviceID) { + throw new Error('Unable to get device ID. Device licenses are only supported on desktop applications.'); + } + + const platform = Phoenix.platform || 'unknown'; + const deviceLabel = `Phoenix Code - ${platform}`; + + const result = await _registerDevice(licenseKey, deviceID, platform, deviceLabel); + + if (result.isSuccess) { + const addSuccess = await KernalModeTrust.loginService.addDeviceLicense(); + const successString = addSuccess ? + Strings.LICENSE_ACTIVATE_SUCCESS : Strings.LICENSE_ACTIVATE_SUCCESS_PARTIAL; + _showActivationMessage($dialog, true, successString); + + // Clear the input field + $dialog.find('#license-key-input').val(''); + + // Refresh license status + await _loadLicenseStatus($dialog); + } else { + _showActivationMessage($dialog, false, result.errorMessage || Strings.LICENSE_ACTIVATE_FAIL); + } + } catch (error) { + _showActivationMessage($dialog, false, error.message || Strings.LICENSE_ACTIVATE_FAIL); + } finally { + // Reset button state + $btn.prop('disabled', false); + $btnText.show(); + $btnSpinner.hide(); + } + } + + /** + * Handle reapply license to device + */ + async function _handleReapplyLicense($dialog) { + const $link = $dialog.find('#reapply-license-link'); + const originalText = $link.html(); + + try { + // Show loading state + $link.html('Applying...'); + $link.css('pointer-events', 'none'); + + const addSuccess = await KernalModeTrust.loginService.addDeviceLicense(); + if (addSuccess) { + _showActivationMessage($dialog, true, Strings.LICENSE_ACTIVATE_SUCCESS); + // Refresh license status + await _loadLicenseStatus($dialog); + } else { + _showActivationMessage($dialog, false, Strings.LICENSE_ACTIVATE_FAIL_APPLY); + } + } catch (error) { + _showActivationMessage($dialog, false, Strings.LICENSE_ACTIVATE_FAIL_APPLY); + } finally { + // Reset link state + $link.html(originalText); + $link.css('pointer-events', 'auto'); + } + } + + async function showManageLicensesDialog() { + const $template = $(Mustache.render(licenseManagementHTML, {Strings})); + + Dialogs.showModalDialogUsingTemplate($template); + + // Set up event handlers + const $dialog = $template; + const $licenseInput = $dialog.find('#license-key-input'); + const $activateBtn = $dialog.find('#activate-license-btn'); + const $reapplyLink = $dialog.find('#reapply-license-link'); + + // Handle activate button click + $activateBtn.on('click', async function() { + const licenseKey = $licenseInput.val().trim(); + if (!licenseKey) { + _showActivationMessage($dialog, false, Strings.LICENSE_ENTER_KEY); + return; + } + + await _handleLicenseActivation($dialog, licenseKey); + }); + + // Handle Enter key in license input + $licenseInput.on('keypress', function(e) { + if (e.which === 13) { // Enter key + $activateBtn.click(); + } + }); + + // Handle reapply license link click + $reapplyLink.on('click', async function(e) { + e.preventDefault(); + await _handleReapplyLicense($dialog); + }); + + // Load current license status + await _loadLicenseStatus($dialog); + } + + exports.showManageLicensesDialog = showManageLicensesDialog; +}); diff --git a/src/services/profile-menu.js b/src/services/profile-menu.js index d5a69ce35b..cbc4218327 100644 --- a/src/services/profile-menu.js +++ b/src/services/profile-menu.js @@ -162,23 +162,41 @@ define(function (require, exports, module) { positionPopup(); - // Check for trial info asynchronously and update popup + // Check for trial info or device license asynchronously and update popup KernalModeTrust.loginService.getEffectiveEntitlements().then(effectiveEntitlements => { - if (effectiveEntitlements && effectiveEntitlements.isInProTrial && isPopupVisible && $popup) { - // Add trial info to the existing popup - const planName = StringUtils.format(Strings.PROMO_PRO_TRIAL_DAYS_LEFT, - effectiveEntitlements.trialDaysRemaining); - const trialInfoHtml = `
- - ${planName} - - -
`; - $popup.find('.popup-title').after(trialInfoHtml); - positionPopup(); // Reposition after adding content + // this is the login popup, so user is not logged in yet if we are here. + if (effectiveEntitlements && isPopupVisible && $popup) { + let proInfoHtml = null; + + if (effectiveEntitlements.isInProTrial) { + // isInProTrial will never be set if user has a pro device license(or a pro sub, + // but that isn't relevant here). Add trial info to the existing popup. + const planName = StringUtils.format(Strings.PROMO_PRO_TRIAL_DAYS_LEFT, + effectiveEntitlements.trialDaysRemaining); + proInfoHtml = `
+ + ${planName} + + +
`; + } else if (effectiveEntitlements.plan && effectiveEntitlements.plan.paidSubscriber) { + // Device-licensed user: show Phoenix Pro branding + const planName = effectiveEntitlements.plan.fullName || brackets.config.main_pro_plan; + proInfoHtml = `
+ + ${planName} + + +
`; + } + + if (proInfoHtml) { + $popup.find('.popup-title').after(proInfoHtml); + positionPopup(); // Reposition after adding content + } } }).catch(error => { - console.error('Failed to check trial info for login popup:', error); + console.error('Failed to check entitlements for login popup:', error); }); PopUpManager.addPopUp($popup, function() { @@ -570,33 +588,38 @@ define(function (require, exports, module) { } /** - * Check if user has an active trial (works for both logged-in and non-logged-in users) + * Check if user has Pro access (active trial or device license) + * Works for both logged-in and non-logged-in users */ - async function _hasActiveTrial() { + async function _hasProActive() { try { const effectiveEntitlements = await KernalModeTrust.loginService.getEffectiveEntitlements(); - return effectiveEntitlements && effectiveEntitlements.isInProTrial; + return effectiveEntitlements && + (effectiveEntitlements.isInProTrial || + (effectiveEntitlements.plan && effectiveEntitlements.plan.paidSubscriber)); } catch (error) { - console.error('Failed to check trial status:', error); + console.error('Failed to check Pro access status:', error); return false; } } /** - * Initialize branding for non-logged-in trial users on startup + * Initialize branding for non-logged-in users with Pro access (trial or device license) on startup */ - async function _initializeBrandingForTrialUsers() { + async function _setBrandingForNonLoggedInUser() { try { const effectiveEntitlements = await KernalModeTrust.loginService.getEffectiveEntitlements(); - if (effectiveEntitlements && effectiveEntitlements.isInProTrial) { - console.log('Profile Menu: Found active trial, updating branding...'); + if (effectiveEntitlements && + (effectiveEntitlements.isInProTrial || + (effectiveEntitlements.plan && effectiveEntitlements.plan.paidSubscriber))) { + console.log('Profile Menu: Found Pro entitlements (trial or device license), updating branding...'); _updateBranding(effectiveEntitlements); } else { - console.log('Profile Menu: No active trial found'); + console.log('Profile Menu: No Pro entitlements found'); _updateBranding(null); } } catch (error) { - console.error('Failed to initialize branding for trial users:', error); + console.error('Failed to initialize branding for non-logged-in Pro users:', error); } } @@ -619,14 +642,14 @@ define(function (require, exports, module) { togglePopup(); }); - // Initialize branding for non-logged-in trial users - _initializeBrandingForTrialUsers(); + // Initialize branding for non-logged-in users with Pro access (trial or device license) + _setBrandingForNonLoggedInUser(); - // Listen for entitlements changes to update branding for non-logged-in trial users + // Listen for entitlements changes to update branding for non-logged-in Pro users KernalModeTrust.loginService.on(KernalModeTrust.loginService.EVENT_ENTITLEMENTS_CHANGED, () => { - // When entitlements change (including trial activation) for non-logged-in users, update branding + // When entitlements change (trial activation or device license) for non-logged-in users, update branding if (!KernalModeTrust.loginService.isLoggedIn()) { - _initializeBrandingForTrialUsers(); + _setBrandingForNonLoggedInUser(); } }); } @@ -638,19 +661,19 @@ define(function (require, exports, module) { } _removeProfileIcon(); - // Reset branding, but preserve trial branding if user has active trial - _hasActiveTrial().then(hasActiveTrial => { - if (!hasActiveTrial) { - // Only reset branding if no trial exists - console.log('Profile Menu: No trial, resetting branding to free'); + // Reset branding, but preserve Pro branding if user has active trial or device license + _hasProActive().then(hasProActive => { + if (!hasProActive) { + // Only reset branding if no trial or device license exists + console.log('Profile Menu: No Pro access, resetting branding to free'); _updateBranding(null); } else { - // User has trial, maintain pro branding - console.log('Profile Menu: Trial exists, maintaining pro branding'); - _initializeBrandingForTrialUsers(); + // User has trial or device license, maintain pro branding + console.log('Profile Menu: Pro access exists, maintaining pro branding'); + _setBrandingForNonLoggedInUser(); } }).catch(error => { - console.error('Failed to check trial status during logout:', error); + console.error('Failed to check Pro access status during logout:', error); // Fallback to resetting branding _updateBranding(null); }); diff --git a/src/styles/phoenix-pro.less b/src/styles/phoenix-pro.less index 60aff4c879..c5dded8bff 100644 --- a/src/styles/phoenix-pro.less +++ b/src/styles/phoenix-pro.less @@ -183,3 +183,230 @@ border: 0; display: block; } + +/* ---- License Management Dialog ---- */ +.license-management-dialog { + /* ---- Layout ---- */ + .license-activation-section { + margin-bottom: 24px; + } + + .license-status-section { + margin-top: 24px; + } + + /* ---- Form Elements ---- */ + .license-form-group { + margin-bottom: 16px; + } + + .license-form-label { + display: block; + margin-bottom: 8px; + font-weight: 500; + font-size: 16px; + color: @bc-text-emphasized; + + .dark & { + color: @dark-bc-text-alt; + } + } + + .license-form-input { + width: 100%; + box-sizing: border-box; + height: 38px; + margin: 0; + } + + /* ---- Activation Button ---- */ + .license-activate-btn { + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; + border-radius: @bc-border-radius; + transition: background-color 0.15s ease, border-color 0.15s ease; + + .btn-spinner { + display: none; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + /* ---- Section Divider ---- */ + .license-divider { + margin: 24px 0; + border: none; + border-top: 1px solid @bc-panel-border; + + .dark & { + border-top-color: @dark-bc-panel-separator; + } + } + + /* ---- Section Headers ---- */ + .license-section-title { + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 500; + color: @bc-text-emphasized; + + .dark & { + color: @dark-bc-text-alt; + } + } + + /* ---- Status States ---- */ + .license-status-loading, + .license-status-none, + .license-status-error { + text-align: center; + padding: 20px; + } + + .license-status-loading { + color: @bc-text-medium; + + .dark & { + color: @dark-bc-text-medium; + } + + .fa-spinner { + margin-right: 8px; + color: @bc-text-medium; + + .dark & { + color: @dark-bc-text-medium; + } + } + } + + .license-status-none { + color: @bc-text-medium; + + .dark & { + color: @dark-bc-text-medium; + } + + .fa-exclamation-circle { + margin-right: 8px; + color: #ff9500; + font-size: 18px; + } + } + + .license-status-error { + color: @bc-error; + + .dark & { + color: @dark-bc-error; + } + + .fa-exclamation-triangle { + margin-right: 8px; + font-size: 18px; + } + } + + /* ---- Valid License Info Card ---- */ + .license-info-card { + border: 1px solid @bc-panel-border; + border-radius: @bc-border-radius-large; + padding: 16px; + background-color: @bc-panel-bg-alt; + + .dark & { + border-color: @dark-bc-highlight; + background-color: @dark-bc-panel-bg-alt; + } + } + + .license-info-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + + .license-info-label { + color: @bc-text-medium; + font-size: 14px; + + .dark & { + color: @dark-bc-text-medium; + } + } + + .license-info-value { + font-weight: 500; + color: @bc-text-emphasized; + font-size: 14px; + + .dark & { + color: @dark-bc-text-alt; + } + } + } + + /* ---- Status Badge ---- */ + .license-status-badge { + padding: 4px 8px; + background-color: #28a745; + color: white; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + } + + /* ---- Activation Messages ---- */ + .license-activation-message { + margin-top: 16px; + padding: 12px; + border-radius: @bc-border-radius; + font-size: 14px; + display: none; + + &.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + + .dark & { + background-color: @dark-bc-success-bg; + color: #4dff4d; + border-color: #2d5a2d; + } + } + + &.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + + .dark & { + background-color: fade(@dark-bc-error, 15%); + color: @dark-bc-error; + border-color: fade(@dark-bc-error, 30%); + } + } + } + + /* ---- Dialog Header ---- */ + .modal-header .dialog-title { + color: @bc-text-emphasized; + + .dark & { + color: @dark-bc-text-alt; + } + } +} diff --git a/src/utils/NodeUtils.js b/src/utils/NodeUtils.js index 5fcb4a90c2..b76d546842 100644 --- a/src/utils/NodeUtils.js +++ b/src/utils/NodeUtils.js @@ -21,6 +21,8 @@ // @INCLUDE_IN_API_DOCS +/*global logger*/ + /** * Generic node util APIs connector. see `src-node/utils.js` for node peer */ @@ -189,6 +191,90 @@ define(function (require, exports, module) { return utilsConnector.execPeer("openInDefaultApp", window.fs.getTauriPlatformPath(fullPath)); } + + let cachedDeviceID = undefined; + /** + * gets the os device id. this usually won't change till os reinstall. + * + * @returns {Promise} - Resolves with the os identifier or null + * @throws {Error} - If called from the browser + */ + async function getDeviceID() { + if (!Phoenix.isNativeApp) { + throw new Error("getDeviceID not available in browser"); + } + if(cachedDeviceID !== undefined) { + return cachedDeviceID; + } + try { + cachedDeviceID = await utilsConnector.execPeer("getDeviceID"); + return cachedDeviceID; + } catch (err) { + cachedDeviceID = null; + logger.reportError(err, "getDeviceID failed in NodeUtils"); + } + return cachedDeviceID; + } + + /** + * Enables device license by creating a system-wide license file. + * On Windows, macOS, and Linux this will request elevation if needed. + * + * @returns {Promise} - Resolves true if system wide defile file added, else false. + * @throws {Error} - If called from the browser + */ + async function addDeviceLicenseSystemWide() { + if (!Phoenix.isNativeApp) { + throw new Error("addDeviceLicense not available in browser"); + } + try { + await utilsConnector.execPeer("addDeviceLicense"); + return true; + } catch (err) { + logger.reportError(err, "system wide device license activation failed"); + } + return false; + } + + /** + * Removes the system-wide device license file. + * On Windows, macOS, and Linux this will request elevation if needed. + * + * @returns {Promise} - Resolves true if system wide defile file removed, else false. + * @throws {Error} - If called from the browser + */ + async function removeDeviceLicenseSystemWide() { + if (!Phoenix.isNativeApp) { + throw new Error("removeDeviceLicense not available in browser"); + } + try { + await utilsConnector.execPeer("removeDeviceLicense"); + return true; + } catch (err) { + logger.reportError(err, "system wide device license remove failed"); + } + return false; + } + + /** + * Checks if the current machine is configured to check for system-wide device license for all users at app start. + * This validates that the system-wide license file exists, contains valid JSON, and has `licensedDevice: true`. + * + * @returns {Promise} - Resolves with `true` if the device is licensed, `false` otherwise. + */ + async function isLicensedDeviceSystemWide() { + if (!Phoenix.isNativeApp) { + console.error("isLicensedDevice not available in browser"); + return false; + } + try { + return utilsConnector.execPeer("isLicensedDevice"); + } catch (err) { + logger.reportError(err, "system wide device check failed"); + } + return false; + } + if(NodeConnector.isNodeAvailable()) { // todo we need to update the strings if a user extension adds its translations. Since we dont support // node extensions for now, should consider when we support node extensions. @@ -227,6 +313,10 @@ define(function (require, exports, module) { exports.getEnvironmentVariable = getEnvironmentVariable; exports.openNativeTerminal = openNativeTerminal; exports.openInDefaultApp = openInDefaultApp; + exports.addDeviceLicenseSystemWide = addDeviceLicenseSystemWide; + exports.removeDeviceLicenseSystemWide = removeDeviceLicenseSystemWide; + exports.isLicensedDeviceSystemWide = isLicensedDeviceSystemWide; + exports.getDeviceID = getDeviceID; /** * checks if Node connector is ready diff --git a/test/spec/login-desktop-integ-test.js b/test/spec/login-desktop-integ-test.js index 3a1c7bc32f..0e46330ddf 100644 --- a/test/spec/login-desktop-integ-test.js +++ b/test/spec/login-desktop-integ-test.js @@ -187,7 +187,11 @@ define(function (require, exports, module) { } else if (url.includes('/getAppEntitlements')) { // Entitlements endpoint - return user's plan and entitlements console.log("llgT: Handling getAppEntitlements call"); - if (userSignedOut) { + + // Check if this is a device ID request (non-logged-in user with device license) + const isDeviceIDRequest = url.includes('deviceID='); + + if (userSignedOut && !isDeviceIDRequest) { return Promise.resolve({ ok: false, status: 401, @@ -207,7 +211,7 @@ define(function (require, exports, module) { entitlementsResponse.plan = { paidSubscriber: true, name: "Phoenix Pro", - fullName: "Phoenix Pro", + fullName: isDeviceIDRequest ? "Phoenix Pro Test Edu" : "Phoenix Pro", validTill: validTill }; entitlementsResponse.entitlements = { diff --git a/test/spec/login-shared.js b/test/spec/login-shared.js index 5c3565f3d7..a97dbfeabc 100644 --- a/test/spec/login-shared.js +++ b/test/spec/login-shared.js @@ -710,6 +710,96 @@ define(function (require, exports, module) { await cleanupTrialState(); } }); + + if (Phoenix.isNativeApp) { + it("should show device-licensed Pro branding and popup when not logged in", async function () { + console.log("llgT: Starting device license Pro branding test"); + + try { + // Setup: Enable device license flag + LoginServiceExports.setIsLicensedDevice(true); + + // Setup mock that handles device ID requests (returns Pro entitlements) + setupProUserMock(true, false); + + // Ensure no trial is active + await cleanupTrialState(); + + // Ensure user is logged out + if (LoginServiceExports.LoginService.isLoggedIn()) { + await performFullLogoutFlow(); + } + + // Clear and refresh entitlements to trigger device license check + await LoginServiceExports.LoginService.clearEntitlements(); + await LoginServiceExports.LoginService.getEffectiveEntitlements(true); + + // Wait for branding to update in the navbar + await awaitsFor( + function () { + const $branding = testWindow.$("#phcode-io-main-nav"); + return $branding.hasClass("phoenix-pro"); + }, + "navbar branding to show Phoenix Pro", + 3000 + ); + + // Verify navbar shows Pro branding (uses plan.name) + await verifyProBranding(true, "device license shows Phoenix Pro branding in navbar"); + + // Verify entitlements API shows Pro access + await verifyPlanEntitlements( + { paidSubscriber: true, name: "Phoenix Pro" }, + "device license provides Pro plan" + ); + await verifyIsInProTrialEntitlement(false, "device license is not a trial"); + await verifyLiveEditEntitlement({ activated: true }, "live edit activated via device license"); + + // Verify raw entitlements are present (not null) + const rawEntitlements = await EntitlementsExports.getRawEntitlements(); + expect(rawEntitlements).toBeDefined(); + expect(rawEntitlements).not.toBeNull(); + expect(rawEntitlements.plan.paidSubscriber).toBe(true); + + // Verify login popup shows Pro branding with fullName + const $profileButton = testWindow.$("#user-profile-button"); + $profileButton.trigger('click'); + await popupToAppear(SIGNIN_POPUP); + + // Wait for Pro branding to appear in popup (async entitlements load) + await awaitsFor( + function () { + const $popup = testWindow.$('.profile-popup'); + const $proInfo = $popup.find('.trial-plan-info'); + return $proInfo.length > 0; + }, + "Pro branding to appear in login popup", + 3000 + ); + + // Check for Pro branding in popup (uses plan.fullName) + const $popup = testWindow.$('.profile-popup'); + const $proInfo = $popup.find('.trial-plan-info'); + expect($proInfo.length).toBe(1); + + const proText = $proInfo.text(); + expect(proText).toContain("Phoenix Pro Test Edu"); + + // Verify feather icon is present + const $featherIcon = $proInfo.find('.fa-feather'); + expect($featherIcon.length).toBe(1); + + // Close popup + $profileButton.trigger('click'); + + console.log("llgT: Device license Pro branding test completed successfully"); + } finally { + // Cleanup: Reset device license flag + LoginServiceExports.setIsLicensedDevice(false); + await LoginServiceExports.LoginService.clearEntitlements(); + } + }); + } } exports.setup = setup;