Source: capacitor-plugin-bbd-base/scripts/hooks/helper.js

/**
 * Copyright (c) 2025 BlackBerry Limited. All Rights Reserved.
 * 
 * BlackBerry Dynamics Capacitor Helper Utilities
 * 
 * PURPOSE:
 * This module provides utility functions used across all BlackBerry Capacitor hooks.
 * It handles file operations, condition checking, logging, and common setup tasks
 * required for BlackBerry Dynamics integration.
 * 
 * KEY FUNCTIONS:
 * 
 * 1. debugLog(message, hookName):
 *    - Centralized logging system for all hooks
 *    - Writes to both console and debug.log file
 *    - Includes timestamps and hook identification
 * 
 * 
 * 2. replaceAndSave(filePath, replacements, options):
 *    - Safe file modification with backup and validation
 *    - Supports multiple replacements in single operation
 *    - Includes conditional replacement based on existing content
 * 
 * 3. Platform-specific utilities:
 *    - patchCAPBridgeViewController(): iOS Capacitor bridge patching
 *    - addAssertDeploymentTarget(): iOS deployment target configuration
 *    - updateDevelopmentInfoJson(): Development tools integration
 * 
 * USAGE:
 * - Imported by bbdCapacitorInstall.js, afterUpdate.js, and other hooks
 * - Provides consistent behavior across all BlackBerry setup operations
 * - Enables centralized debugging and error handling
 */

import path from "path";
import fs from "fs";
import { execSync } from "child_process";
import {
  registerGDStateChangeHandler,
  notificationCenter,
  requireHelperPhrase,
  capacitorPodsHelperPhrase,
  postInstallPhrase,
  assertDeploymentTargetReplacePhrase,
  headers,
  linkerFlags,
  loadWebView,
} from "./constants.js";

const projectRoot = process.env.INIT_CWD,
  packageJson = JSON.parse(
    fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8")
  );

// Debug logging configuration - enables/disables all hook logging
const BBD_DEBUG_ENABLED = false;

/**
 * Centralized debug logging function for all BlackBerry hooks
 * 
 * @param {string} message - The debug message to log
 * @param {string} hookName - Name of the hook/script calling this function
 * 
 * Features:
 * - Writes to both console and debug.log file
 * - Includes ISO timestamp for precise timing analysis
 * - Uses hook name as prefix for easy identification
 * - Maintains consistent format across all hooks
 */
export const debugLog = (message, hookName = "HELPER") => {
  if (!BBD_DEBUG_ENABLED) return;
  
  const timestamp = new Date().toLocaleString();
  const logMessage = `[DEBUG] ${timestamp} - [${hookName}] ${message}\n`;
  
  // Log to console
  console.log(`[DEBUG] ${timestamp} - [${hookName}] ${message}`);
  
  // Log to file
  const debugLogPath = path.join(projectRoot, "debug.log");
  fs.appendFileSync(debugLogPath, logMessage, "utf-8");
};

const cAPBridgeViewControllerPath = path.join(
  projectRoot,
  "node_modules",
  "@capacitor",
  "ios",
  "Capacitor",
  "Capacitor",
  "CAPBridgeViewController.swift"
);




export const getPackageNameFromAndroidManifest = (pathToAndroidManifest) => {
  const androidManifestContent = fs.readFileSync(
      pathToAndroidManifest,
      "utf-8"
    ),
    startIndexOfPackageString =
      androidManifestContent.indexOf(
        '"',
        androidManifestContent.indexOf("package=")
      ) + 1,
    endIndexOfPackageString = androidManifestContent.indexOf(
      '"',
      startIndexOfPackageString
    );

  return androidManifestContent.substring(
    startIndexOfPackageString,
    endIndexOfPackageString
  );
};

export const addAttributeToXmlElement = (element, attributeToAdd, xml) => {
  if (!xml.includes(attributeToAdd)) {
    const startIndexOfElementTag = xml.indexOf("<" + element),
      endIndexOfElementStartLine = xml.indexOf("\n", startIndexOfElementTag),
      nextInlineAttribute = xml.substring(
        startIndexOfElementTag + 1 + element.length,
        endIndexOfElementStartLine
      ),
      elementIdentationsNumber =
        (startIndexOfElementTag -
          xml.lastIndexOf("\n", startIndexOfElementTag)) /
        4,
      attributeIndentation = "\t\t".repeat(elementIdentationsNumber + 1);

    xml = xml.replace(
      element,
      element + "\n" + attributeIndentation + attributeToAdd
    );

    if (nextInlineAttribute.trim()) {
      xml = xml.replace(
        nextInlineAttribute,
        "\n" + attributeIndentation + nextInlineAttribute.trim()
      );
    }

    return xml;
  }

  return xml;
};

export const updateLinkerFlags = () => {
  for (const [key, value] of Object.entries(linkerFlags)) {
    if ("cordova-plugin-bbd-" + key in packageJson.dependencies) {
      addLinkerForBuildType("debug", value);
      addLinkerForBuildType("release", value);
    }
  }
};

/**
 * Patches Capacitor's iOS bridge controller for BlackBerry integration
 * 
 * Modifies CAPBridgeViewController.swift to:
 * - Add BlackBerry headers and imports
 * - Register GD state change handlers
 * - Initialize BlackBerry notification center
 * 
 * This enables BlackBerry Dynamics to integrate with Capacitor's iOS bridge
 * 
 * IDEMPOTENT: Safe to run multiple times - checks if patches already exist
 */
export const patchCAPBridgeViewController = () => {
  if (!fs.existsSync(cAPBridgeViewControllerPath)) {
    debugLog(`CAPBridgeViewController not found at: ${cAPBridgeViewControllerPath}`, "helper.js");
    return;
  }
  
  const fileContent = fs.readFileSync(cAPBridgeViewControllerPath, "utf-8");
  
  // Check if BlackBerry patches are already applied
  const hasBlackBerryHeaders = fileContent.includes(headers.BlackBerry);
  const hasNotificationCenter = fileContent.includes(notificationCenter);
  const hasStateChangeHandler = fileContent.includes(registerGDStateChangeHandler[0]);
  
  if (hasBlackBerryHeaders && hasNotificationCenter && hasStateChangeHandler) {
    debugLog(`CAPBridgeViewController already patched - skipping`, "helper.js");
    return;
  }
  
  debugLog(`Applying BlackBerry patches to CAPBridgeViewController`, "helper.js");
  
  replaceAndSave(cAPBridgeViewControllerPath, [
    [headers.Cordova, `${headers.Cordova}\n${headers.BlackBerry}`],
    [loadWebView, notificationCenter],
    [
      `// MARK: - Initialization`,
      `${registerGDStateChangeHandler.join("\n")}\n\t// MARK: - Initialization`,
    ],
  ]);

  debugLog(`CAPBridgeViewController patching completed`, "helper.js");
};

export const cleanUpCAPBridgeViewController = () => {
  replaceAndSave(cAPBridgeViewControllerPath, [
    [`${headers.Cordova}\n${headers.BlackBerry}`, headers.Cordova],
    [notificationCenter, loadWebView],
    [registerGDStateChangeHandler.join("\n"), ""],
  ]);
};

export const addAssertDeploymentTarget = (capacitorPodFile) => {
  let podFileContent = fs
    .readFileSync(capacitorPodFile, { encoding: "utf-8" })
    .toString();

  if (podFileContent.includes("assertDeploymentTarget(installer)")) {
    podFileContent = podFileContent.replace(
      assertDeploymentTargetReplacePhrase,
      ""
    );
  }

  if (
    !podFileContent.includes(requireHelperPhrase) &&
    !podFileContent.includes(postInstallPhrase)
  ) {
    podFileContent = podFileContent + postInstallPhrase;
    podFileContent = podFileContent.replace(
      capacitorPodsHelperPhrase,
      `${capacitorPodsHelperPhrase}\n${requireHelperPhrase}`
    );
    fs.writeFileSync(capacitorPodFile, podFileContent, "utf-8");
  }
};

export const removeAssertDeploymentTarget = (capacitorPodFile) => {
  replaceAndSave(capacitorPodFile, [
    [requireHelperPhrase, ""],
    [postInstallPhrase, assertDeploymentTargetReplacePhrase],
  ]);
};

/**
 * Safe file modification utility with validation and backup
 * 
 * @param {string} filePath - Path to file to modify
 * @param {Array} collection - Array of [search, replace] pairs or regex replacements
 * @param {Object} options - Configuration options
 * @param {string} options.replacementTextToCheck - Text that must exist to proceed
 * @param {boolean} options.revert - Whether to reverse the replacements
 * 
 * Features:
 * - Validates file exists before modification
 * - Supports both string and regex replacements
 * - Conditional replacement based on existing content
 * - Safe error handling with detailed logging
 */
export const replaceAndSave = (
  filePath,
  collection,
  { replacementTextToCheck = "", revert = false } = {}
) => {
  if (!fs.existsSync(filePath)) {
    throw new Error(`File not exists at path ${filePath}`);
  }
  const encoding = { encoding: "utf8" };

  let fileContent = fs.readFileSync(filePath, encoding);

  if (
    !replacementTextToCheck ||
    (replacementTextToCheck && !fileContent.includes(replacementTextToCheck)) ||
    revert
  ) {
    for (const [target, replacement] of collection) {
      fileContent = revert
        ? fileContent.replace(replacement, target)
        : fileContent.replace(target, replacement);
    }

    fs.writeFileSync(filePath, fileContent, encoding);
  }
};

function addLinkerForBuildType(buildType, linker) {
  const xcconfigPath = path.join(
    projectRoot,
    "ios",
    "App",
    "Pods",
    "Target Support Files",
    "Pods-App",
    "Pods-App." + buildType + ".xcconfig"
  );

  if (fs.existsSync(xcconfigPath)) {
    replaceAndSave(xcconfigPath, [
      [
        '-framework "BlackBerryDynamics" ',
        '-framework "BlackBerryDynamics" ' + linker,
      ],
    ]);
  }
}

function getBlackBerryDynamicsPodPhrase(context) {
  const [match] = context.match(
    /pod 'BlackBerryDynamics', (:podspec|:path) => '(.+)'/
  );
  return match;
}

function addAfter(phrase, newPhrase) {
  return `${phrase}\n\t${newPhrase}`;
}

/**
 * Updates development tools information for BlackBerry integration
 * 
 * Creates development-tools-info.json files in both Android and iOS projects
 * containing framework version information, Ionic details, and BlackBerry SDK version.
 * This information is used by BlackBerry development tools and debugging utilities.
 */
export const updateDevelopmentInfoJson = () => {
  const cordovaInfoJsonPath = path.join(
      projectRoot,
      "node_modules",
      "capacitor-plugin-bbd-base",
      "assets",
      "development-tools-info.json"
    ),
    cordovaInfoJson = JSON.parse(fs.readFileSync(cordovaInfoJsonPath, "utf8")),
    updatedCordovaInfo = getCordovaFrameworkInfo();

  // DEVNOTE: add constant value of bbdSdkForCordovaVersion, that was already set on Jenkins job during building 'Base' plugin
  updatedCordovaInfo.framework.bbdSdkForCordovaVersion =
    cordovaInfoJson.framework.bbdSdkForCordovaVersion;

  //create development-tools-info.json for android if platform is added
  if (fs.existsSync(path.join(projectRoot, "android"))) {
    const targetAndroidDirectory = path.join(
      projectRoot,
      "android",
      "app",
      "src",
      "main",
      "assets"
    );
    if (!fs.existsSync(targetAndroidDirectory)) {
      fs.mkdirSync(targetAndroidDirectory);
    }
    const androidCordovaInfoJsonPath = path.join(
      projectRoot,
      "android",
      "app",
      "src",
      "main",
      "assets",
      "development-tools-info.json"
    );
    fs.cpSync(
      cordovaInfoJsonPath,
      path.join(targetAndroidDirectory, "development-tools-info.json")
    );
    if (fs.existsSync(androidCordovaInfoJsonPath)) {
      storeCordovaFrameworkInfoInJson(
        updatedCordovaInfo,
        androidCordovaInfoJsonPath
      );
    }
  }

  //create development-tools-info.json for ios if platform is added
  if (fs.existsSync(path.join(projectRoot, "ios"))) {
    const targetiOSDirectory = path.join(
      projectRoot,
      "ios",
      "App",
      "App",
      "Resources"
    );
    if (!fs.existsSync(targetiOSDirectory)) {
      fs.mkdirSync(targetiOSDirectory);
    }
    fs.cpSync(
      cordovaInfoJsonPath,
      path.join(targetiOSDirectory, "development-tools-info.json")
    );
    const iosCordovaInfoJsonPath = path.join(
      projectRoot,
      "ios",
      "App",
      "App",
      "Resources",
      "development-tools-info.json"
    );
    if (fs.existsSync(iosCordovaInfoJsonPath)) {
      storeCordovaFrameworkInfoInJson(
        updatedCordovaInfo,
        iosCordovaInfoJsonPath
      );
    }
  }
};

export const patchXCFrameworkLinkerFlagsFix = (capacitorCliUpdateJsPath) => {
  debugLog("Applying complete getLinkerFlags function replacement", "bbdCapacitorInstall.js");

      // Find and replace the entire getLinkerFlags function
  const originalFunction = `function getLinkerFlags(config) {
    var _a;
    if ((_a = config.app.extConfig.ios) === null || _a === void 0 ? void 0 : _a.cordovaLinkerFlags) {
        return \`\\n    s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '\${config.app.extConfig.ios.cordovaLinkerFlags.join(' ')}' }\`;
    }
    return '';
}`;

      const blackberryFunction = `function getLinkerFlags(config) {
    var _a;
    let flags = '-all_load'; // BlackBerry requires -all_load
    if ((_a = config.app.extConfig.ios) === null || _a === void 0 ? void 0 : _a.cordovaLinkerFlags) {
        // Combine user flags with BlackBerry required flags
        const userFlags = config.app.extConfig.ios.cordovaLinkerFlags.join(' ');
        flags = userFlags.includes('-all_load') ? userFlags : \`\${userFlags} -all_load\`;
    }
    return \`\\n    s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '\${flags}' }\`;
}`;

  replaceAndSave(
    capacitorCliUpdateJsPath,
    [
      [originalFunction, blackberryFunction]
    ]
  );
}

const getCordovaVersion = () => {
  const cordovaCmd = process.env.CORDOVA_BIN || "cordova";
  const osEol = "\n";
  return execSync(cordovaCmd + " -v")
    .toString()
    .split(osEol)[0]
    .trim();
};

const getIonicInfo = () => {
  var IONIC_INFO_KEYS = {
    ionicCli: "Ionic CLI",
    capCli: "Capacitor CLI",
    framework: "Ionic Framework",
  };

  try {
    const projectPath = projectRoot;
    var ionicInfoJson = execSync("ionic info --json ", {
        stdio: "pipe",
        cwd: projectPath,
      }).toString(),
      ionicInfoList = JSON.parse(ionicInfoJson),
      ionicFramework = ionicInfoList.find(function (paramsObj) {
        return (
          paramsObj.key === IONIC_INFO_KEYS.framework ||
          paramsObj.name === IONIC_INFO_KEYS.framework
        );
      });

    if (ionicFramework) {
      var ionicCliValue,
        capCliValue,
        ionicFrameworkValue,
        ionicProjectTypeValue = execSync("ionic config get type --no-color", {
          cwd: projectPath,
        })
          .toString()
          .trim()
          .replace(/[/'"]+/g, "")
          .replace("\u001b[32m", "")
          .replace("\u001b[39m", "");

      ionicInfoList.forEach(function (ionicInfo) {
        Object.keys(ionicInfo).forEach(function (key) {
          switch (ionicInfo[key]) {
            case IONIC_INFO_KEYS.ionicCli:
              ionicCliValue = ionicInfo.value;
              break;
            case IONIC_INFO_KEYS.framework:
              ionicFrameworkValue = ionicInfo.value;
              break;
            case IONIC_INFO_KEYS.capCli:
              capCliValue = ionicInfo.value;
              break;
            default:
              break;
          }
        });
      });
    }
  } catch (e) {
    // Ionic is not installed.
    // It is optional so we shouldn't do any actions here
  }
  return {
    cli: ionicCliValue || "not installed",
    framework: ionicFrameworkValue || "not installed",
    capacitor: capCliValue || "not installed",
    type: ionicProjectTypeValue || "not installed",
  };
};

const getCordovaFrameworkInfo = () => {
  return {
    framework: {
      name: "Cordova",
      bbdSdkForCordovaVersion: "",
      version: getCordovaVersion(),
      ionic: getIonicInfo(),
    },
  };
};

const storeCordovaFrameworkInfoInJson = (
  cordovaInfoObj,
  cordovaInfoJsonPath
) => {
  fs.writeFileSync(
    cordovaInfoJsonPath,
    JSON.stringify(cordovaInfoObj, null, 2),
    "utf8"
  );
};