From ade508d5f077721de74457a19e2c9910d3bc973e Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 13:58:19 +0530 Subject: [PATCH 1/7] refactor: node constants file --- src-node/constants.js | 3 + src-node/licence-device.js | 185 +++++++++++++++++++++++++++++++++++++ src-node/utils.js | 178 +---------------------------------- src/utils/NodeUtils.js | 15 +++ 4 files changed, 208 insertions(+), 173 deletions(-) create mode 100644 src-node/constants.js create mode 100644 src-node/licence-device.js diff --git a/src-node/constants.js b/src-node/constants.js new file mode 100644 index 0000000000..368d078416 --- /dev/null +++ b/src-node/constants.js @@ -0,0 +1,3 @@ +exports.SYSTEM_SETTINGS_DIR_WIN = 'C:\\Program Files\\Phoenix Code Control\\'; +exports.SYSTEM_SETTINGS_DIR_MAC = '/Library/Application Support/phoenix-code-control/'; +exports.SYSTEM_SETTINGS_DIR_LINUX = '/etc/phoenix-code-control/'; diff --git a/src-node/licence-device.js b/src-node/licence-device.js new file mode 100644 index 0000000000..7cca56fc7d --- /dev/null +++ b/src-node/licence-device.js @@ -0,0 +1,185 @@ +const os = require('os'); +const sudo = require('@expo/sudo-prompt'); +const fs = require('fs'); +const fsPromise = require('fs').promises; +const path = require('path'); +const { exec } = require('child_process'); +const { SYSTEM_SETTINGS_DIR_WIN, SYSTEM_SETTINGS_DIR_MAC, SYSTEM_SETTINGS_DIR_LINUX } = require('./constants'); + +const options = { name: 'Phoenix Code' }; +const licenseFileContent = JSON.stringify({}); + +function getLicensePath() { + switch (os.platform()) { + case 'win32': + return `${SYSTEM_SETTINGS_DIR_WIN}device-license`; + case 'darwin': + return `${SYSTEM_SETTINGS_DIR_MAC}device-license`; + case 'linux': + return `${SYSTEM_SETTINGS_DIR_LINUX}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.addDeviceLicense = addDeviceLicense; +exports.removeDeviceLicense = removeDeviceLicense; +exports.isLicensedDevice = isLicensedDevice; +exports.getDeviceID = getDeviceID; diff --git a/src-node/utils.js b/src-node/utils.js index 7167d60356..9af6845a0f 100644 --- a/src-node/utils.js +++ b/src-node/utils.js @@ -4,13 +4,10 @@ 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"); +const { lintFile } = require("./ESLint/service"); +const { addDeviceLicense, getDeviceID, isLicensedDevice, removeDeviceLicense } = require("./licence-device"); let openModule, open; // dynamic import when needed -const options = { name: 'Phoenix Code' }; -const licenseFileContent = JSON.stringify({}); - async function _importOpen() { if(open){ return open; @@ -273,174 +270,8 @@ 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}`); +async function getOSUserName() { + return os.userInfo().username; } exports.getURLContent = getURLContent; @@ -456,5 +287,6 @@ exports.addDeviceLicense = addDeviceLicense; exports.removeDeviceLicense = removeDeviceLicense; exports.isLicensedDevice = isLicensedDevice; exports.getDeviceID = getDeviceID; +exports.getOSUserName = getOSUserName; exports._loadNodeExtensionModule = _loadNodeExtensionModule; exports._npmInstallInFolder = _npmInstallInFolder; diff --git a/src/utils/NodeUtils.js b/src/utils/NodeUtils.js index b76d546842..6f8cd29977 100644 --- a/src/utils/NodeUtils.js +++ b/src/utils/NodeUtils.js @@ -275,6 +275,20 @@ define(function (require, exports, module) { return false; } + /** + * Retrieves the operating system username of the current user. + * This method is only available in native apps. + * + * @throws {Error} Throws an error if called in a browser environment. + * @return {Promise} A promise that resolves to the OS username of the current user. + */ + async function getOSUserName() { + if (!Phoenix.isNativeApp) { + throw new Error("getOSUserName not available in browser"); + } + return utilsConnector.execPeer("getOSUserName"); + } + 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. @@ -317,6 +331,7 @@ define(function (require, exports, module) { exports.removeDeviceLicenseSystemWide = removeDeviceLicenseSystemWide; exports.isLicensedDeviceSystemWide = isLicensedDeviceSystemWide; exports.getDeviceID = getDeviceID; + exports.getOSUserName = getOSUserName; /** * checks if Node connector is ready From 1fe35f2fc8fb4a63a86b4e7905b5bf718c6ca445 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 14:29:26 +0530 Subject: [PATCH 2/7] chore: add NodeUtils.getSystemSettingsDir --- src-node/constants.js | 13 +++++++++++++ src-node/licence-device.js | 13 ++----------- src-node/utils.js | 6 ++++++ src/utils/NodeUtils.js | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src-node/constants.js b/src-node/constants.js index 368d078416..33845f4d93 100644 --- a/src-node/constants.js +++ b/src-node/constants.js @@ -1,3 +1,16 @@ +const os = require('os'); + exports.SYSTEM_SETTINGS_DIR_WIN = 'C:\\Program Files\\Phoenix Code Control\\'; exports.SYSTEM_SETTINGS_DIR_MAC = '/Library/Application Support/phoenix-code-control/'; exports.SYSTEM_SETTINGS_DIR_LINUX = '/etc/phoenix-code-control/'; + +switch (os.platform()) { +case 'win32': + exports.SYSTEM_SETTINGS_DIR = exports.SYSTEM_SETTINGS_DIR_WIN; break; +case 'darwin': + exports.SYSTEM_SETTINGS_DIR = exports.SYSTEM_SETTINGS_DIR_MAC; break; +case 'linux': + exports.SYSTEM_SETTINGS_DIR = exports.SYSTEM_SETTINGS_DIR_LINUX; break; +default: + throw new Error(`Unsupported platform: ${os.platform()}`); +} diff --git a/src-node/licence-device.js b/src-node/licence-device.js index 7cca56fc7d..b0bf76aef4 100644 --- a/src-node/licence-device.js +++ b/src-node/licence-device.js @@ -4,22 +4,13 @@ const fs = require('fs'); const fsPromise = require('fs').promises; const path = require('path'); const { exec } = require('child_process'); -const { SYSTEM_SETTINGS_DIR_WIN, SYSTEM_SETTINGS_DIR_MAC, SYSTEM_SETTINGS_DIR_LINUX } = require('./constants'); +const { SYSTEM_SETTINGS_DIR } = require('./constants'); const options = { name: 'Phoenix Code' }; const licenseFileContent = JSON.stringify({}); function getLicensePath() { - switch (os.platform()) { - case 'win32': - return `${SYSTEM_SETTINGS_DIR_WIN}device-license`; - case 'darwin': - return `${SYSTEM_SETTINGS_DIR_MAC}device-license`; - case 'linux': - return `${SYSTEM_SETTINGS_DIR_LINUX}device-license`; - default: - throw new Error(`Unsupported platform: ${os.platform()}`); - } + return `${SYSTEM_SETTINGS_DIR}device-license`; } function sudoExec(command) { diff --git a/src-node/utils.js b/src-node/utils.js index 9af6845a0f..d2e4525edc 100644 --- a/src-node/utils.js +++ b/src-node/utils.js @@ -4,6 +4,7 @@ const fs = require('fs'); const fsPromise = require('fs').promises; const path = require('path'); const os = require('os'); +const { SYSTEM_SETTINGS_DIR } = require('./constants'); const { lintFile } = require("./ESLint/service"); const { addDeviceLicense, getDeviceID, isLicensedDevice, removeDeviceLicense } = require("./licence-device"); let openModule, open; // dynamic import when needed @@ -274,6 +275,10 @@ async function getOSUserName() { return os.userInfo().username; } +async function getSystemSettingsDir() { + return SYSTEM_SETTINGS_DIR; +} + exports.getURLContent = getURLContent; exports.setLocaleStrings = setLocaleStrings; exports.getPhoenixBinaryVersion = getPhoenixBinaryVersion; @@ -288,5 +293,6 @@ exports.removeDeviceLicense = removeDeviceLicense; exports.isLicensedDevice = isLicensedDevice; exports.getDeviceID = getDeviceID; exports.getOSUserName = getOSUserName; +exports.getSystemSettingsDir = getSystemSettingsDir; exports._loadNodeExtensionModule = _loadNodeExtensionModule; exports._npmInstallInFolder = _npmInstallInFolder; diff --git a/src/utils/NodeUtils.js b/src/utils/NodeUtils.js index 6f8cd29977..38e8c5325b 100644 --- a/src/utils/NodeUtils.js +++ b/src/utils/NodeUtils.js @@ -289,6 +289,23 @@ define(function (require, exports, module) { return utilsConnector.execPeer("getOSUserName"); } + let _systemSettingsDir; + /** + * Retrieves the directory path for system settings. This method is applicable to native apps only. + * + * @return {Promise} A promise that resolves to the path of the system settings directory. + * @throws {Error} If the method is called in browser app. + */ + async function getSystemSettingsDir() { + if (!Phoenix.isNativeApp) { + throw new Error("getSystemSettingsDir is sudo folder is win/linux/mac. not available in browser."); + } + if(!_systemSettingsDir){ + _systemSettingsDir = await utilsConnector.execPeer("getSystemSettingsDir"); + } + return _systemSettingsDir; + } + 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. @@ -332,6 +349,7 @@ define(function (require, exports, module) { exports.isLicensedDeviceSystemWide = isLicensedDeviceSystemWide; exports.getDeviceID = getDeviceID; exports.getOSUserName = getOSUserName; + exports.getSystemSettingsDir = getSystemSettingsDir; /** * checks if Node connector is ready From 97ca88c5cce35260de9a906361f1892120cabc0f Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 14:38:44 +0530 Subject: [PATCH 3/7] chore: deprecate phoenix ai control extension as it will be built in --- src/extensibility/ExtensionManagerView.js | 6 +++--- src/extensions/default/DefaultExtensions.json | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/extensibility/ExtensionManagerView.js b/src/extensibility/ExtensionManagerView.js index 842b1e14f3..7e6e0b99dc 100644 --- a/src/extensibility/ExtensionManagerView.js +++ b/src/extensibility/ExtensionManagerView.js @@ -43,8 +43,8 @@ define(function (require, exports, module) { PathUtils = require("thirdparty/path-utils/path-utils"), itemTemplate = require("text!htmlContent/extension-manager-view-item.html"), PreferencesManager = require("preferences/PreferencesManager"), - warnExtensionIDs = JSON.parse(require("text!extensions/default/DefaultExtensions.json")) - .warnExtensionStoreExtensions.extensionIDs, + warnExtensionIDs = new Set(JSON.parse(require("text!extensions/default/DefaultExtensions.json")) + .warnExtensionStoreExtensions.extensionIDs), Metrics = require("utils/Metrics"); @@ -363,7 +363,7 @@ define(function (require, exports, module) { context.isCurrentTheme = entry.installInfo && (entry.installInfo.metadata.name === ThemeManager.getCurrentTheme().name); - context.defaultFeature = warnExtensionIDs.includes(info.metadata.name); + context.defaultFeature = warnExtensionIDs.has(info.metadata.name); context.allowInstall = context.isCompatible && !context.isInstalled; diff --git a/src/extensions/default/DefaultExtensions.json b/src/extensions/default/DefaultExtensions.json index fd1fc5483f..68ec9d2ae8 100644 --- a/src/extensions/default/DefaultExtensions.json +++ b/src/extensions/default/DefaultExtensions.json @@ -34,7 +34,11 @@ "extensionIDs": [ "brackets-beautify", "beautify.io", "hirse.beautify", "jsbeautifier", "sizuhiko.brackets.prettier", "pretty_json", "bib-locked-live-preview", "brackets-display-shortcuts", "changing-tags", "brackets-indent-guides", "brackets-emmet", - "github-Pluto-custom-line-height" + "github-Pluto-custom-line-height", "github-phcode-dev-phoenix-code-ai-control" ] - } + }, + "dontLoadExtensions": { + "note": "Use this only for first party extensions. we should always allow third party extension loading.", + "extensionIDs": ["github-phcode-dev-phoenix-code-ai-control"] + } } From 8d43a8a6313ced50a14dc66f195d34dad32ae33a Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 15:04:36 +0530 Subject: [PATCH 4/7] feat: way to not load first party deprecated extensions --- src/extensibility/ExtensionManagerViewModel.js | 18 +++++++++++++----- src/utils/ExtensionLoader.js | 6 ++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/extensibility/ExtensionManagerViewModel.js b/src/extensibility/ExtensionManagerViewModel.js index 0cc62781b5..677f589bac 100644 --- a/src/extensibility/ExtensionManagerViewModel.js +++ b/src/extensibility/ExtensionManagerViewModel.js @@ -26,11 +26,13 @@ define(function (require, exports, module) { var _ = require("thirdparty/lodash"); - var ExtensionManager = require("extensibility/ExtensionManager"), - registry_utils = require("extensibility/registry_utils"), - EventDispatcher = require("utils/EventDispatcher"), - Strings = require("strings"), - PreferencesManager = require("preferences/PreferencesManager"); + const ExtensionManager = require("extensibility/ExtensionManager"), + registry_utils = require("extensibility/registry_utils"), + EventDispatcher = require("utils/EventDispatcher"), + Strings = require("strings"), + PreferencesManager = require("preferences/PreferencesManager"), + DefaultExtensions = JSON.parse(require("text!extensions/default/DefaultExtensions.json")), + dontLoadExtensionIDs = new Set(DefaultExtensions.dontLoadExtensions.extensionIDs); /** * @private @@ -307,6 +309,9 @@ define(function (require, exports, module) { return entry.registryInfo && entry.registryInfo.metadata.theme; }) + .filter(function (entry) { + return !dontLoadExtensionIDs.has(entry.registryInfo.metadata.name); + }) .map(function (entry) { return entry.registryInfo.metadata.name; }); @@ -555,6 +560,9 @@ define(function (require, exports, module) { .filter(function (key) { return self.extensions[key].installInfo && self.extensions[key].installInfo.locationType === ExtensionManager.LOCATION_DEFAULT; + }) + .filter(function (key) { + return !dontLoadExtensionIDs.has(key); }); this._sortFullSet(); this._setInitialFilter(); diff --git a/src/utils/ExtensionLoader.js b/src/utils/ExtensionLoader.js index 52ae93d3b4..5e2f0ca737 100644 --- a/src/utils/ExtensionLoader.js +++ b/src/utils/ExtensionLoader.js @@ -57,6 +57,7 @@ define(function (require, exports, module) { DefaultExtensions = JSON.parse(require("text!extensions/default/DefaultExtensions.json")); const desktopOnlyExtensions = DefaultExtensions.desktopOnly; + const dontLoadExtensionIDs = new Set(DefaultExtensions.dontLoadExtensions.extensionIDs); const DefaultExtensionsList = Phoenix.isNativeApp ? [...DefaultExtensions.defaultExtensionsList, ...desktopOnlyExtensions]: DefaultExtensions.defaultExtensionsList; @@ -420,6 +421,11 @@ define(function (require, exports, module) { if (metadata && metadata.theme) { return; } + if (dontLoadExtensionIDs.has(metadata.name)) { + logger.leaveTrail("skipping extension in dontLoadExtensions list: " + metadata.name); + console.warn("skipping extension in dontLoadExtensions list: " + metadata.name); + return new $.Deferred().reject("disabled").promise(); + } if (!metadata.disabled) { return loadExtensionModule(name, config, entryPoint, metadata); From 42335e3604ed26728bad0cfa4631eba6fcaeb737 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 15:13:49 +0530 Subject: [PATCH 5/7] feat: show waring that deprecated extension is not loaded in installed view --- src/extensibility/ExtensionManagerView.js | 10 ++++++---- src/htmlContent/extension-manager-view-item.html | 5 +++++ src/nls/root/strings.js | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/extensibility/ExtensionManagerView.js b/src/extensibility/ExtensionManagerView.js index 7e6e0b99dc..ba5912c51a 100644 --- a/src/extensibility/ExtensionManagerView.js +++ b/src/extensibility/ExtensionManagerView.js @@ -43,8 +43,9 @@ define(function (require, exports, module) { PathUtils = require("thirdparty/path-utils/path-utils"), itemTemplate = require("text!htmlContent/extension-manager-view-item.html"), PreferencesManager = require("preferences/PreferencesManager"), - warnExtensionIDs = new Set(JSON.parse(require("text!extensions/default/DefaultExtensions.json")) - .warnExtensionStoreExtensions.extensionIDs), + DefaultExtensions = JSON.parse(require("text!extensions/default/DefaultExtensions.json")), + warnExtensionIDs = new Set(DefaultExtensions.warnExtensionStoreExtensions.extensionIDs), + dontLoadExtensionIDs = new Set(DefaultExtensions.dontLoadExtensions.extensionIDs), Metrics = require("utils/Metrics"); @@ -364,6 +365,7 @@ define(function (require, exports, module) { (entry.installInfo.metadata.name === ThemeManager.getCurrentTheme().name); context.defaultFeature = warnExtensionIDs.has(info.metadata.name); + context.isDeprecatedExtension = dontLoadExtensionIDs.has(info.metadata.name); context.allowInstall = context.isCompatible && !context.isInstalled; @@ -419,9 +421,9 @@ define(function (require, exports, module) { var isDefaultOrInstalled = this.model.source === "default" || this.model.source === "installed"; var isDefaultAndTheme = this.model.source === "default" && context.metadata.theme; context.disablingAllowed = isDefaultOrInstalled && !isDefaultAndTheme && !context.disabled - && !hasPendingAction && !context.metadata.theme; + && !hasPendingAction && !context.metadata.theme && !context.isDeprecatedExtension; context.enablingAllowed = isDefaultOrInstalled && !isDefaultAndTheme && context.disabled - && !hasPendingAction && !context.metadata.theme; + && !hasPendingAction && !context.metadata.theme && !context.isDeprecatedExtension; // Copy over helper functions that we share with the registry app. ["lastVersionDate", "authorInfo"].forEach(function (helper) { diff --git a/src/htmlContent/extension-manager-view-item.html b/src/htmlContent/extension-manager-view-item.html index 6f31e4e438..893ea928bd 100644 --- a/src/htmlContent/extension-manager-view-item.html +++ b/src/htmlContent/extension-manager-view-item.html @@ -39,6 +39,11 @@ + {{#isDeprecatedExtension}} + {{#isInstalled}} +
{{Strings.EXTENSION_DEPRECATED_NOT_LOADED}}
+ {{/isInstalled}} + {{/isDeprecatedExtension}} {{#showInstallButton}} {{#defaultFeature}} diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 0c148a9cdb..0d87dfd8d8 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -809,6 +809,7 @@ define({ "EXTENSION_INCOMPATIBLE_NEWER": "This extension requires a newer version of {APP_NAME}.", "EXTENSION_INCOMPATIBLE_OLDER": "This extension currently only works with older versions of {APP_NAME}.", "EXTENSION_DEFAULT_FEATURE_PRESENT": "You may not need this extension. {APP_NAME} already has this feature.", + "EXTENSION_DEPRECATED_NOT_LOADED": "Extension is deprecated and not loaded.", "EXTENSION_LATEST_INCOMPATIBLE_NEWER": "Version {0} of this extension requires a newer version of {APP_NAME}. But you can install the earlier version {1}.", "EXTENSION_LATEST_INCOMPATIBLE_OLDER": "Version {0} of this extension only works with older versions of {APP_NAME}. But you can install the earlier version {1}.", "EXTENSION_NO_DESCRIPTION": "No description", From abdbc9e825f2ed62965d602d3ca77d15b8291bc7 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 16:41:50 +0530 Subject: [PATCH 6/7] chore: phoenix ai control extension reinstate as it will not be widely used in core --- src/extensions/default/DefaultExtensions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/DefaultExtensions.json b/src/extensions/default/DefaultExtensions.json index 68ec9d2ae8..7862961e5c 100644 --- a/src/extensions/default/DefaultExtensions.json +++ b/src/extensions/default/DefaultExtensions.json @@ -34,11 +34,11 @@ "extensionIDs": [ "brackets-beautify", "beautify.io", "hirse.beautify", "jsbeautifier", "sizuhiko.brackets.prettier", "pretty_json", "bib-locked-live-preview", "brackets-display-shortcuts", "changing-tags", "brackets-indent-guides", "brackets-emmet", - "github-Pluto-custom-line-height", "github-phcode-dev-phoenix-code-ai-control" + "github-Pluto-custom-line-height" ] }, "dontLoadExtensions": { "note": "Use this only for first party extensions. we should always allow third party extension loading.", - "extensionIDs": ["github-phcode-dev-phoenix-code-ai-control"] + "extensionIDs": [] } } From ada632236cbe3708142e3f7b23c8633b6a1cb389 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 4 Oct 2025 18:58:34 +0530 Subject: [PATCH 7/7] feat: support takedown of extensions via updates.phcode.io/extension_takedown.json --- docs/API-Reference/utils/NodeUtils.md | 23 ++++ src/config.json | 1 + src/extensibility/ExtensionManager.js | 1 + src/extensibility/ExtensionManagerView.js | 3 +- .../ExtensionManagerViewModel.js | 8 +- src/extensions/default/DefaultExtensions.json | 3 +- src/nls/root/strings.js | 2 +- src/utils/ExtensionLoader.js | 116 +++++++++++++++++- 8 files changed, 141 insertions(+), 16 deletions(-) diff --git a/docs/API-Reference/utils/NodeUtils.md b/docs/API-Reference/utils/NodeUtils.md index e8a03dbd0e..9243e256a3 100644 --- a/docs/API-Reference/utils/NodeUtils.md +++ b/docs/API-Reference/utils/NodeUtils.md @@ -150,3 +150,26 @@ This validates that the system-wide license file exists, contains valid JSON, an **Kind**: global function **Returns**: Promise.<boolean> - - Resolves with `true` if the device is licensed, `false` otherwise. + + +## getOSUserName() ⇒ Promise.<string> +Retrieves the operating system username of the current user. +This method is only available in native apps. + +**Kind**: global function +**Returns**: Promise.<string> - A promise that resolves to the OS username of the current user. +**Throws**: + +- Error Throws an error if called in a browser environment. + + + +## getSystemSettingsDir() ⇒ Promise.<string> +Retrieves the directory path for system settings. This method is applicable to native apps only. + +**Kind**: global function +**Returns**: Promise.<string> - A promise that resolves to the path of the system settings directory. +**Throws**: + +- Error If the method is called in browser app. + diff --git a/src/config.json b/src/config.json index 393c4010fd..c60bc4f6e6 100644 --- a/src/config.json +++ b/src/config.json @@ -29,6 +29,7 @@ "extension_store_url": "https://store.core.ai/src/", "app_notification_url": "assets/notifications/dev/", "app_update_url": "https://updates.phcode.io/tauri/update-latest-experimental-build.json", + "extensionTakedownURL": "https://updates.phcode.io/extension_takedown.json", "linting.enabled_by_default": true, "build_timestamp": "", "googleAnalyticsID": "G-P4HJFPDB76", diff --git a/src/extensibility/ExtensionManager.js b/src/extensibility/ExtensionManager.js index 70953fa292..68fd3e99fc 100644 --- a/src/extensibility/ExtensionManager.js +++ b/src/extensibility/ExtensionManager.js @@ -1006,6 +1006,7 @@ define(function (require, exports, module) { exports.updateExtensions = updateExtensions; exports.getAvailableUpdates = getAvailableUpdates; exports.cleanAvailableUpdates = cleanAvailableUpdates; + exports.isExtensionTakenDown = ExtensionLoader.isExtensionTakenDown; exports.ENABLED = ENABLED; exports.DISABLED = DISABLED; diff --git a/src/extensibility/ExtensionManagerView.js b/src/extensibility/ExtensionManagerView.js index ba5912c51a..4af6f0b9ee 100644 --- a/src/extensibility/ExtensionManagerView.js +++ b/src/extensibility/ExtensionManagerView.js @@ -45,7 +45,6 @@ define(function (require, exports, module) { PreferencesManager = require("preferences/PreferencesManager"), DefaultExtensions = JSON.parse(require("text!extensions/default/DefaultExtensions.json")), warnExtensionIDs = new Set(DefaultExtensions.warnExtensionStoreExtensions.extensionIDs), - dontLoadExtensionIDs = new Set(DefaultExtensions.dontLoadExtensions.extensionIDs), Metrics = require("utils/Metrics"); @@ -365,7 +364,7 @@ define(function (require, exports, module) { (entry.installInfo.metadata.name === ThemeManager.getCurrentTheme().name); context.defaultFeature = warnExtensionIDs.has(info.metadata.name); - context.isDeprecatedExtension = dontLoadExtensionIDs.has(info.metadata.name); + context.isDeprecatedExtension = ExtensionManager.isExtensionTakenDown(info.metadata.name); context.allowInstall = context.isCompatible && !context.isInstalled; diff --git a/src/extensibility/ExtensionManagerViewModel.js b/src/extensibility/ExtensionManagerViewModel.js index 677f589bac..98bf3a246c 100644 --- a/src/extensibility/ExtensionManagerViewModel.js +++ b/src/extensibility/ExtensionManagerViewModel.js @@ -30,9 +30,7 @@ define(function (require, exports, module) { registry_utils = require("extensibility/registry_utils"), EventDispatcher = require("utils/EventDispatcher"), Strings = require("strings"), - PreferencesManager = require("preferences/PreferencesManager"), - DefaultExtensions = JSON.parse(require("text!extensions/default/DefaultExtensions.json")), - dontLoadExtensionIDs = new Set(DefaultExtensions.dontLoadExtensions.extensionIDs); + PreferencesManager = require("preferences/PreferencesManager"); /** * @private @@ -310,7 +308,7 @@ define(function (require, exports, module) { }) .filter(function (entry) { - return !dontLoadExtensionIDs.has(entry.registryInfo.metadata.name); + return !ExtensionManager.isExtensionTakenDown(entry.registryInfo.metadata.name); }) .map(function (entry) { return entry.registryInfo.metadata.name; @@ -562,7 +560,7 @@ define(function (require, exports, module) { self.extensions[key].installInfo.locationType === ExtensionManager.LOCATION_DEFAULT; }) .filter(function (key) { - return !dontLoadExtensionIDs.has(key); + return !ExtensionManager.isExtensionTakenDown(key); }); this._sortFullSet(); this._setInitialFilter(); diff --git a/src/extensions/default/DefaultExtensions.json b/src/extensions/default/DefaultExtensions.json index 7862961e5c..8de57731af 100644 --- a/src/extensions/default/DefaultExtensions.json +++ b/src/extensions/default/DefaultExtensions.json @@ -38,7 +38,6 @@ ] }, "dontLoadExtensions": { - "note": "Use this only for first party extensions. we should always allow third party extension loading.", - "extensionIDs": [] + "note": "To take down any compromised extensions, see `extensionTakedownURL` in repo" } } diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 0d87dfd8d8..2862b95152 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -809,7 +809,7 @@ define({ "EXTENSION_INCOMPATIBLE_NEWER": "This extension requires a newer version of {APP_NAME}.", "EXTENSION_INCOMPATIBLE_OLDER": "This extension currently only works with older versions of {APP_NAME}.", "EXTENSION_DEFAULT_FEATURE_PRESENT": "You may not need this extension. {APP_NAME} already has this feature.", - "EXTENSION_DEPRECATED_NOT_LOADED": "Extension is deprecated and not loaded.", + "EXTENSION_DEPRECATED_NOT_LOADED": "Extension not loaded. It is either deprecated or insecure.", "EXTENSION_LATEST_INCOMPATIBLE_NEWER": "Version {0} of this extension requires a newer version of {APP_NAME}. But you can install the earlier version {1}.", "EXTENSION_LATEST_INCOMPATIBLE_OLDER": "Version {0} of this extension only works with older versions of {APP_NAME}. But you can install the earlier version {1}.", "EXTENSION_NO_DESCRIPTION": "No description", diff --git a/src/utils/ExtensionLoader.js b/src/utils/ExtensionLoader.js index 5e2f0ca737..35d793436c 100644 --- a/src/utils/ExtensionLoader.js +++ b/src/utils/ExtensionLoader.js @@ -56,8 +56,97 @@ define(function (require, exports, module) { PathUtils = require("thirdparty/path-utils/path-utils"), DefaultExtensions = JSON.parse(require("text!extensions/default/DefaultExtensions.json")); + // takedown/dont load extensions that are compromised at app start - start + const EXTENSION_TAKEDOWN_LOCALSTORAGE_KEY = "PH_EXTENSION_TAKEDOWN_LIST"; + + function _getTakedownListLS() { + try{ + let list = localStorage.getItem(EXTENSION_TAKEDOWN_LOCALSTORAGE_KEY); + if(list) { + list = JSON.parse(list); + if (Array.isArray(list)) { + return list; + } + } + } catch (e) { + console.error(e); + } + return []; + } + + const loadedExtensionIDs = new Set(); + let takedownExtensionList = new Set(_getTakedownListLS()); + + const EXTENSION_TAKEDOWN_URL = brackets.config.extensionTakedownURL; + + function _anyTakenDownExtensionLoaded() { + if (takedownExtensionList.size === 0 || loadedExtensionIDs.size === 0) { + return []; + } + let smaller; + let larger; + + if (takedownExtensionList.size < loadedExtensionIDs.size) { + smaller = takedownExtensionList; + larger = loadedExtensionIDs; + } else { + smaller = loadedExtensionIDs; + larger = takedownExtensionList; + } + + const matches = []; + + for (const id of smaller) { + if (larger.has(id)) { + matches.push(id); + } + } + + return matches; + } + + function fetchWithTimeout(url, ms) { + const c = new AbortController(); + const t = setTimeout(() => c.abort(), ms); + return fetch(url, { signal: c.signal }).finally(() => clearTimeout(t)); + } + + // we dont want a restart after user does too much in the app causing data loss. So we wont reload after 20 seconds. + fetchWithTimeout(EXTENSION_TAKEDOWN_URL, 20000) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status} - ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Extension takedown data:', data); + if (!Array.isArray(data) || !data.every(x => typeof x === "string")) { + console.error("Takedown list must be an array of strings."); + return; + } + const dataToWrite = JSON.stringify(data); + localStorage.setItem(EXTENSION_TAKEDOWN_LOCALSTORAGE_KEY, dataToWrite); + takedownExtensionList = new Set(data); + const compromisedExtensionsLoaded = _anyTakenDownExtensionLoaded(); + if(!compromisedExtensionsLoaded.length){ + return; + } + // if we are here, we have already loaded some compromised extensions. we need to reload app as soon as + // possible. no await after this. all sync js calls to prevent extension from tampering with this list. + const writtenData = localStorage.getItem(EXTENSION_TAKEDOWN_LOCALSTORAGE_KEY); + if(writtenData !== dataToWrite) { + // the write did not succeded. local storage write can fail if storage full, if so we may cause infinite + // reloads here if we dont do the check. + console.error("Failed to write taken down extension to localstorage"); + return; + } + location.reload(); + }) + .catch(console.error); + // takedown/dont load extensions that are compromised at app start - end + const desktopOnlyExtensions = DefaultExtensions.desktopOnly; - const dontLoadExtensionIDs = new Set(DefaultExtensions.dontLoadExtensions.extensionIDs); const DefaultExtensionsList = Phoenix.isNativeApp ? [...DefaultExtensions.defaultExtensionsList, ...desktopOnlyExtensions]: DefaultExtensions.defaultExtensionsList; @@ -417,15 +506,20 @@ define(function (require, exports, module) { return promise .then(function (metadata) { + if (isExtensionTakenDown(metadata.name)) { + logger.leaveTrail("skip load taken down extension: " + metadata.name); + console.warn("skip load taken down extension: " + metadata.name); + return new $.Deferred().reject("disabled").promise(); + } + + if(metadata.name) { + loadedExtensionIDs.add(metadata.name); + } + // No special handling for themes... Let the promise propagate into the ExtensionManager if (metadata && metadata.theme) { return; } - if (dontLoadExtensionIDs.has(metadata.name)) { - logger.leaveTrail("skipping extension in dontLoadExtensions list: " + metadata.name); - console.warn("skipping extension in dontLoadExtensions list: " + metadata.name); - return new $.Deferred().reject("disabled").promise(); - } if (!metadata.disabled) { return loadExtensionModule(name, config, entryPoint, metadata); @@ -929,6 +1023,15 @@ define(function (require, exports, module) { return promise; } + function isExtensionTakenDown(extensionID) { + if(!extensionID){ + // extensions without id can happen with local development. these are never distributed in store. + // so safe to return false here. + return false; + } + return takedownExtensionList.has(extensionID); + } + EventDispatcher.makeEventDispatcher(exports); @@ -952,6 +1055,7 @@ define(function (require, exports, module) { exports.testExtension = testExtension; exports.loadAllExtensionsInNativeDirectory = loadAllExtensionsInNativeDirectory; exports.loadExtensionFromNativeDirectory = loadExtensionFromNativeDirectory; + exports.isExtensionTakenDown = isExtensionTakenDown; exports.testAllExtensionsInNativeDirectory = testAllExtensionsInNativeDirectory; exports.testAllDefaultExtensions = testAllDefaultExtensions; exports.EVENT_EXTENSION_LOADED = EVENT_EXTENSION_LOADED;