UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

1,023 lines (1,022 loc) 52.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.XcodeProject = void 0; /* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ // @ts-expect-error - clack is ESM and TS complains about that. It works though const clack = __importStar(require("@clack/prompts")); const fs = __importStar(require("node:fs")); const path = __importStar(require("node:path")); const debug_1 = require("../utils/debug"); const templates = __importStar(require("./templates")); const xcode_1 = require("xcode"); const macos_system_helper_1 = require("./macos-system-helper"); function setDebugInformationFormatAndSandbox(proj, targetName) { const xcObjects = proj.hash.project.objects; if (!xcObjects.PBXNativeTarget) { xcObjects.PBXNativeTarget = {}; } const targetKey = Object.keys(xcObjects.PBXNativeTarget).filter((key) => { const value = xcObjects.PBXNativeTarget?.[key]; return (!key.endsWith('_comment') && typeof value !== 'string' && value?.name === targetName); })[0]; const target = xcObjects.PBXNativeTarget[targetKey]; if (!xcObjects.XCBuildConfiguration) { xcObjects.XCBuildConfiguration = {}; } if (!xcObjects.XCConfigurationList) { xcObjects.XCConfigurationList = {}; } const buildConfigurationListId = target?.buildConfigurationList ?? ''; const configurationList = xcObjects.XCConfigurationList?.[buildConfigurationListId]; const buildListConfigurationIds = configurationList?.buildConfigurations ?? []; for (const buildListConfigId of buildListConfigurationIds) { const config = xcObjects.XCBuildConfiguration[buildListConfigId.value] ?? {}; if (typeof config === 'string') { // Ignore comments continue; } const buildSettings = config.buildSettings ?? {}; buildSettings.DEBUG_INFORMATION_FORMAT = '"dwarf-with-dsym"'; buildSettings.ENABLE_USER_SCRIPT_SANDBOXING = '"NO"'; config.buildSettings = buildSettings; xcObjects.XCBuildConfiguration[buildListConfigId.value] = config; } } function addSentrySPM(proj, targetName) { const xcObjects = proj.hash.project.objects; const sentryFrameworkUUID = proj.generateUuid(); const sentrySPMUUID = proj.generateUuid(); // Check whether xcObjects already have sentry framework if (xcObjects.PBXFrameworksBuildPhase) { for (const key in xcObjects.PBXFrameworksBuildPhase || {}) { const frameworkBuildPhase = xcObjects.PBXFrameworksBuildPhase[key]; if (key.endsWith('_comment') || typeof frameworkBuildPhase === 'string') { // Ignore comments continue; } for (const framework of frameworkBuildPhase.files ?? []) { // We identify the Sentry framework by the comment "Sentry in Frameworks", // which is set by this manager in previous runs. if (framework.comment === 'Sentry in Frameworks') { return; } } } } if (!xcObjects.PBXBuildFile) { xcObjects.PBXBuildFile = {}; } xcObjects.PBXBuildFile[sentryFrameworkUUID] = { isa: 'PBXBuildFile', productRef: sentrySPMUUID, productRef_comment: 'Sentry', }; xcObjects.PBXBuildFile[`${sentryFrameworkUUID}_comment`] = 'Sentry in Frameworks'; if (!xcObjects.PBXFrameworksBuildPhase) { xcObjects.PBXFrameworksBuildPhase = {}; } for (const key in xcObjects.PBXFrameworksBuildPhase) { const value = xcObjects.PBXFrameworksBuildPhase[key]; if (key.endsWith('_comment') || typeof value === 'string') { // Ignore comments continue; } const frameworks = value.files ?? []; frameworks.push({ value: sentryFrameworkUUID, comment: 'Sentry in Frameworks', }); value.files = frameworks; xcObjects.PBXFrameworksBuildPhase[key] = value; } if (!xcObjects.PBXNativeTarget) { xcObjects.PBXNativeTarget = {}; } const targetKey = Object.keys(xcObjects.PBXNativeTarget || {}).filter((key) => { const value = xcObjects.PBXNativeTarget?.[key]; return (!key.endsWith('_comment') && typeof value !== 'string' && value?.name === targetName); })[0]; const target = xcObjects.PBXNativeTarget[targetKey]; if (!target.packageProductDependencies) { target.packageProductDependencies = []; } target.packageProductDependencies.push({ value: sentrySPMUUID, comment: 'Sentry', }); const sentrySwiftPackageUUID = proj.generateUuid(); const xcProject = proj.getFirstProject().firstProject; if (!xcProject.packageReferences) { xcProject.packageReferences = []; } xcProject.packageReferences.push({ value: sentrySwiftPackageUUID, comment: 'XCRemoteSwiftPackageReference "sentry-cocoa"', }); if (!xcObjects.XCRemoteSwiftPackageReference) { xcObjects.XCRemoteSwiftPackageReference = {}; } xcObjects.XCRemoteSwiftPackageReference[sentrySwiftPackageUUID] = { isa: 'XCRemoteSwiftPackageReference', repositoryURL: '"https://github.com/getsentry/sentry-cocoa/"', requirement: { kind: 'upToNextMajorVersion', minimumVersion: '8.0.0', }, }; xcObjects.XCRemoteSwiftPackageReference[`${sentrySwiftPackageUUID}_comment`] = 'XCRemoteSwiftPackageReference "sentry-cocoa"'; if (!xcObjects.XCSwiftPackageProductDependency) { xcObjects.XCSwiftPackageProductDependency = {}; } xcObjects.XCSwiftPackageProductDependency[sentrySPMUUID] = { isa: 'XCSwiftPackageProductDependency', package: sentrySwiftPackageUUID, package_comment: 'XCRemoteSwiftPackageReference "sentry-cocoa"', productName: 'Sentry', }; xcObjects.XCSwiftPackageProductDependency[`${sentrySPMUUID}_comment`] = 'Sentry'; clack.log.step('Added Sentry SPM dependency to your project'); } class XcodeProject { /** * The directory where the Xcode project is located. */ baseDir; /** * The path to the `<PROJECT>.xcodeproj` directory. */ xcodeprojPath; /** * The path to the `project.pbxproj` file. */ pbxprojPath; /** * The Xcode project object. */ project; objects; /** * Creates a new XcodeProject instance, a wrapper around the Xcode project file `<PROJECT>.xcodeproj/project.pbxproj`. * * @param projectPath - The path to the Xcode project file */ constructor(projectPath) { this.pbxprojPath = projectPath; this.xcodeprojPath = path.dirname(projectPath); this.baseDir = path.dirname(this.xcodeprojPath); this.project = (0, xcode_1.project)(projectPath); this.project.parseSync(); this.objects = this.project.hash.project.objects; } getAllTargets() { const targets = this.objects.PBXNativeTarget ?? {}; return Object.keys(targets) .filter((key) => { const value = targets[key]; return (!key.endsWith('_comment') && typeof value !== 'string' && value.productType.startsWith('"com.apple.product-type.application')); }) .map((key) => { return targets[key].name; }); } updateXcodeProject(sentryProject, target, addSPMReference, uploadSource = true) { this.addUploadSymbolsScript({ sentryProject, targetName: target, uploadSource, }); if (uploadSource) { setDebugInformationFormatAndSandbox(this.project, target); } if (addSPMReference) { addSentrySPM(this.project, target); } const newContent = this.project.writeSync(); fs.writeFileSync(this.pbxprojPath, newContent); } addUploadSymbolsScript({ sentryProject, targetName, uploadSource, }) { const xcObjects = this.project.hash.project.objects; if (!xcObjects.PBXNativeTarget) { xcObjects.PBXNativeTarget = {}; } const targetKey = Object.keys(xcObjects.PBXNativeTarget).filter((key) => { const value = xcObjects.PBXNativeTarget?.[key]; return (!key.endsWith('_comment') && typeof value !== 'string' && value?.name === targetName); })[0]; const target = xcObjects.PBXNativeTarget[targetKey]; if (!target) { (0, debug_1.debug)(`Target not found: ${targetName}`); return; } // Generate the new script content const isHomebrewInstalled = fs.existsSync('/opt/homebrew/bin/sentry-cli'); const shellScript = templates.getRunScriptTemplate(sentryProject.organization.slug, sentryProject.slug, uploadSource, isHomebrewInstalled); if (!xcObjects.PBXShellScriptBuildPhase) { xcObjects.PBXShellScriptBuildPhase = {}; } // Look for existing Sentry build phase in the current target let existingSentryBuildPhaseId; let existingSentryBuildPhase; // Check target's build phases for existing Sentry script by searching for a build phase that contains "sentry-cli" in the shell script if (target.buildPhases) { for (const phase of target.buildPhases) { const buildPhase = xcObjects.PBXShellScriptBuildPhase[phase.value]; if (typeof buildPhase === 'object' && buildPhase.shellScript?.includes('sentry-cli')) { existingSentryBuildPhaseId = phase.value; existingSentryBuildPhase = buildPhase; break; } } } // Clean up orphaned build phase references that may exist from previous runs // Find all build phase IDs that are referenced in targets but don't exist in PBXShellScriptBuildPhase const orphanedBuildPhaseIds = []; for (const targetKey in xcObjects.PBXNativeTarget) { const targetValue = xcObjects.PBXNativeTarget[targetKey]; if (typeof targetValue === 'object' && targetValue.buildPhases) { for (const phase of targetValue.buildPhases) { // Check if this is a shell script build phase that doesn't exist if (!xcObjects.PBXShellScriptBuildPhase?.[phase.value] && phase.comment?.includes('Upload Debug Symbols to Sentry')) { orphanedBuildPhaseIds.push(phase.value); } } } } // Remove orphaned build phase references from all targets if (orphanedBuildPhaseIds.length > 0) { for (const targetKey in xcObjects.PBXNativeTarget) { const targetValue = xcObjects.PBXNativeTarget[targetKey]; if (typeof targetValue === 'object' && targetValue.buildPhases) { targetValue.buildPhases = targetValue.buildPhases.filter((phase) => { return !orphanedBuildPhaseIds.includes(phase.value); }); } } } if (existingSentryBuildPhaseId && existingSentryBuildPhase) { // Update existing build phase instead of adding a new one // This call is idempotent, so it will not add a new build phase if it already exists this.updateScriptBuildPhase(existingSentryBuildPhaseId, shellScript, [ templates.scriptInputPath, ]); clack.log.step(`Updated existing Sentry upload script for "${targetName}" build phase`); } else { // Add new build phase to the target this.addScriptBuildPhase(targetKey, 'Upload Debug Symbols to Sentry', shellScript, [templates.scriptInputPath]); clack.log.step(`Added Sentry upload script to "${targetName}" build phase`); } } /** * Retrieves all source files associated with a specific target in the Xcode project. * This is used to find files where we can inject Sentry initialization code. * * @param targetName - The name of the target to get files for * @returns An array of absolute file paths for the target's source files, or undefined if target not found */ getSourceFilesForTarget(targetName) { // ## Summary how Xcode Projects are structured: // - Every Xcode Project has exactly one main group of type `PBXGroup` // - The main group contains a list of children identifiers // - Each child can be a `PBXGroup`, a `PBXFileReference` or a `PBXFileSystemSynchronizedRootGroup` // - Each `PBXGroup` has a list of children identifiers which again can be `PBXGroup`, `PBXFileReference` or `PBXFileSystemSynchronizedRootGroup` // - The target defines the list of `fileSystemSynchronizedGroups` which are `PBXFileSystemSynchronizedRootGroup` to be included in the build phase // - The `PBXFileSystemSynchronizedRootGroup` has a list of `exceptions` which are `PBXFileSystemSynchronizedBuildFileExceptionSet` // - Each `PBXFileSystemSynchronizedBuildFileExceptionSet` represents a folder to be excluded from the build. // - The `PBXFileSystemSynchronizedBuildFileExceptionSet` has a list of `membershipExceptions` which are files to be excluded from being excluded, therefore included in the build. // - The Xcode project has a build phase `PBXSourcesBuildPhase` which has a list of `files` which are `PBXBuildFile` // - A file which is not part of a `PBXFileSystemSynchronizedRootGroup` must be added to the `files` list of the `PBXSourcesBuildPhase` build phase // - Nested subfolders in `fileSystemSynchronizedGroups` are not declared but recursively included // // Based on the findings above the files included in the build phase are: // - All files in the `files` of the `PBXSourcesBuildPhase` build phase `Sources` of the target // - All files in directories of the `fileSystemSynchronizedGroups` of the target // - Excluding all files in the `exceptions` of the `PBXFileSystemSynchronizedRootGroup` of the target // - Including all files in the `membershipExceptions` of the `PBXFileSystemSynchronizedBuildFileExceptionSet` of the target const nativeTarget = this.findNativeTargetByName(targetName); if (!nativeTarget) { (0, debug_1.debug)('Target not found: ' + targetName); return undefined; } const filesInBuildPhase = this.findFilesInSourceBuildPhase(nativeTarget); (0, debug_1.debug)(`Found ${filesInBuildPhase.length} files in build phase for target: ${targetName}`); const filesInSynchronizedRootGroups = this.findFilesInSynchronizedRootGroups(nativeTarget); (0, debug_1.debug)(`Found ${filesInSynchronizedRootGroups.length} files in synchronized root groups for target: ${targetName}`); return [...filesInBuildPhase, ...filesInSynchronizedRootGroups]; } // ================================ TARGET HELPERS ================================ /** * Finds a native target by name. * * @param targetName - The name of the target to find * @returns The native target, or undefined if the target is not found */ findNativeTargetByName(targetName) { (0, debug_1.debug)('Finding native target by name: ' + targetName); if (!this.objects.PBXNativeTarget) { (0, debug_1.debug)('No native targets found'); return undefined; } const nativeTargets = Object.entries(this.objects.PBXNativeTarget); for (const [key, target] of nativeTargets) { // Ignore comments if (key.endsWith('_comment') || typeof target === 'string') { continue; } // Ignore targets that are not the target we are looking for if (target.name !== targetName) { continue; } (0, debug_1.debug)('Found native target: ' + targetName); return { id: key, obj: target, }; } (0, debug_1.debug)('Target not found: ' + targetName); return undefined; } // ================================ BUILD PHASE HELPERS ================================ /** * Finds the source build phase in a target. * * @param target - The target to find the source build phase in * @returns The source build phase, or undefined if the target is not found or has no source build phase */ findSourceBuildPhaseInTarget(target) { (0, debug_1.debug)(`Finding source build phase in target: ${target.name}`); if (!target.buildPhases) { (0, debug_1.debug)('No build phases found for target: ' + target.name); return undefined; } for (const phase of target.buildPhases) { const buildPhaseId = phase.value; const buildPhase = this.objects.PBXSourcesBuildPhase?.[buildPhaseId]; if (typeof buildPhase !== 'object') { // Ignore comments continue; } (0, debug_1.debug)(`Found source build phase: ${buildPhaseId} for target: ${target.name}`); return { id: buildPhaseId, obj: buildPhase, }; } (0, debug_1.debug)(`No source build phase found for target: ${target.name}`); return undefined; } /** * Adds a new script build phase to the specified target. * * @param targetKey - The key of the target to add the build phase to * @param name - The name of the build phase * @param script - The shell script content * @param inputPaths - Array of input paths for the script * @returns The UUID of the created build phase */ addScriptBuildPhase(targetKey, name, script, inputPaths = []) { const buildPhaseUuid = this.project.generateUuid(); const escapedScript = script.replace(/"/g, '\\"'); // Create the shell script build phase object const buildPhase = { isa: 'PBXShellScriptBuildPhase', buildActionMask: 2147483647, files: [], inputPaths, outputPaths: [], runOnlyForDeploymentPostprocessing: 0, shellPath: '/bin/sh', shellScript: `"${escapedScript}"`, name: `"${name}"`, }; // Add to PBXShellScriptBuildPhase section if (!this.objects.PBXShellScriptBuildPhase) { this.objects.PBXShellScriptBuildPhase = {}; } this.objects.PBXShellScriptBuildPhase[buildPhaseUuid] = buildPhase; this.objects.PBXShellScriptBuildPhase[`${buildPhaseUuid}_comment`] = name; // Add to target's build phases const target = this.objects.PBXNativeTarget?.[targetKey]; if (target?.buildPhases) { target.buildPhases.push({ value: buildPhaseUuid, comment: name, }); } return buildPhaseUuid; } /** * Updates an existing script build phase. * * @param buildPhaseId - The UUID of the build phase to update * @param script - The new shell script content * @param inputPaths - Array of input paths for the script */ updateScriptBuildPhase(buildPhaseId, script, inputPaths = []) { const buildPhase = this.objects.PBXShellScriptBuildPhase?.[buildPhaseId]; if (!buildPhase || typeof buildPhase === 'string') { (0, debug_1.debug)(`Build phase not found: ${buildPhaseId}`); return; } const escapedScript = script.replace(/"/g, '\\"'); buildPhase.shellScript = `"${escapedScript}"`; buildPhase.inputPaths = inputPaths; buildPhase.shellPath = '/bin/sh'; } // ================================ FILE HELPERS ================================ /** * Finds all files in the source build phase of a target. * * @param nativeTarget - The target to find the files in * @returns The files in the source build phase of the target, or an empty array if the target is not found or has no source build phase */ findFilesInSourceBuildPhase(nativeTarget) { (0, debug_1.debug)('Finding files in source build phase for target: ' + nativeTarget.obj.name); const buildPhase = this.findSourceBuildPhaseInTarget(nativeTarget.obj); if (!buildPhase) { (0, debug_1.debug)(`Sources build phase not found for target: ${nativeTarget.obj.name}`); return []; } const buildPhaseFiles = buildPhase.obj.files; if (!buildPhaseFiles) { (0, debug_1.debug)(`No files found in sources build phase for target: ${nativeTarget.obj.name}`); return []; } if (!this.objects.PBXBuildFile) { (0, debug_1.debug)('PBXBuildFile is undefined'); return []; } if (!this.objects.PBXFileReference) { (0, debug_1.debug)('PBXFileReference is undefined'); return []; } const result = []; for (const file of buildPhaseFiles) { (0, debug_1.debug)(`Resolving build phase file: ${file.value}`); // Find the related build file object const buildFileObj = this.objects.PBXBuildFile[file.value]; if (!buildFileObj || typeof buildFileObj !== 'object') { (0, debug_1.debug)(`Build file object not found for file: ${file.value}`); continue; } (0, debug_1.debug)(`Build file object found for file: ${file.value}`); const buildFileRefId = buildFileObj.fileRef; if (!buildFileRefId) { (0, debug_1.debug)(`File reference not found for file: ${file.value}`); continue; } (0, debug_1.debug)(`Build file reference found for file: ${file.value}`); // Find the related file reference object const buildFile = this.objects.PBXFileReference[buildFileRefId]; if (!buildFile || typeof buildFile !== 'object') { (0, debug_1.debug)(`File not found in file dictionary for file: ${file.value}`); continue; } (0, debug_1.debug)(`Build file found in file dictionary for file: ${file.value}`); // Resolve the path of the file based on the `sourceTree` property const resolvedFilePath = this.resolveAbsolutePathOfFileReference({ id: buildFileRefId, obj: buildFile, }); if (!resolvedFilePath) { (0, debug_1.debug)(`Failed to resolve file path for file: ${file.value}`); continue; } (0, debug_1.debug)(`Resolved file ${file.value} to path: ${resolvedFilePath}`); result.push(resolvedFilePath); } (0, debug_1.debug)(`Resolved ${result.length} files for target: ${nativeTarget.obj.name}`); return result; } /** * Resolves the absolute path of a file reference. * * @param fileRef - The file reference to resolve the path of * @returns The absolute path of the file reference, or undefined if the file reference is not found or has no path */ resolveAbsolutePathOfFileReference(fileRef) { (0, debug_1.debug)(`Resolving path of file reference: ${fileRef.id} with path: ${fileRef.obj.path}`); // File path is expected to be set, therefore typing is non-nullable. // As the file is loaded from a project file, it is not guaranteed to be set, // therefore we treat it as optional. if (!fileRef.obj.path) { (0, debug_1.debug)(`File reference path not found for file reference: ${fileRef.id}`); return undefined; } // File references are resolved based on the `sourceTree` property // which can have one of the following values: // - '<absolute>': The file path is absolute // - '<group>': The file path is relative to the parent group of the file reference // - 'BUILT_PRODUCTS_DIR': The file path is relative to the built products directory, i.e. the build output directory in derived data // - 'SOURCE_ROOT': The file path is relative to the source root, i.e. the directory where the Xcode project is located // - 'SDKROOT': The file path is relative to the SDK root, i.e. the directory where the SDK is installed // - 'DEVELOPER_DIR': The file path is relative to the developer directory, i.e. the directory where the Xcode command line tools are installed // The default is '<group>' const fileRefSourceTree = fileRef.obj.sourceTree?.replace(/"/g, '') ?? ''; switch (fileRefSourceTree) { case '<absolute>': return fileRef.obj.path.replace(/"/g, ''); case '<group>': return this.resolveAbsoluteFilePathRelativeToGroup(fileRef); case 'BUILT_PRODUCTS_DIR': return this.resolveAbsoluteFilePathRelativeToBuiltProductsDir(fileRef); case 'SOURCE_ROOT': return this.resolveAbsoluteFilePathRelativeToSourceRoot(fileRef); case 'SDKROOT': return this.resolveAbsoluteFilePathRelativeToSdkRoot(fileRef); case 'DEVELOPER_DIR': return this.resolveAbsoluteFilePathRelativeToDeveloperDir(fileRef); default: (0, debug_1.debug)(`Unknown source tree '${fileRef.obj.sourceTree}' for build file: ${fileRef.obj.path}`); return undefined; } } /** * Resolves the absolute path of a file reference relative to the parent group. * * @param fileRef - The file reference to resolve the path of * @returns The absolute path of the file reference, or undefined if the file reference is not found or has no path */ resolveAbsoluteFilePathRelativeToGroup(fileRef) { (0, debug_1.debug)(`Resolving absolute file path relative to group for file reference: ${fileRef.id} with path: ${fileRef.obj.path ?? ''}`); const fileRefPath = fileRef.obj.path?.replace(/"/g, ''); if (!fileRefPath) { (0, debug_1.debug)(`File reference path not found for file reference: ${fileRef.id}`); return undefined; } // Find the parent group of the file reference by searching for the reverse relationship const parentGroup = this.findParentGroupByChildId(fileRef.id); if (!parentGroup) { (0, debug_1.debug)(`Parent group not found for file reference: ${fileRef.id} at path: ${fileRefPath}`); return undefined; } // Resolve the path of the parent group const absoluteGroupPath = this.resolveAbsolutePathOfGroup(parentGroup); if (!absoluteGroupPath) { (0, debug_1.debug)(`Failed to resolve path of group: ${parentGroup.id}`); return undefined; } return path.join(absoluteGroupPath, fileRefPath); } /** * Resolves the absolute path of a file reference relative to the built products directory. * * @param buildFile - The file reference to resolve the path of * @returns The absolute path of the file reference, or undefined if the file reference is not found or has no path */ resolveAbsoluteFilePathRelativeToBuiltProductsDir(buildFile) { (0, debug_1.debug)(`Resolving absolute file path relative to built products directory for file reference: ${buildFile.id} with path: ${buildFile.obj.path ?? ''}`); const builtProductsDir = this.getBuildProductsDirectoryPath(); if (!builtProductsDir) { (0, debug_1.debug)(`Failed to resolve built products directory path`); return undefined; } return path.join(builtProductsDir, buildFile.obj.path.replace(/"/g, '')); } /** * Resolves the absolute path of a file reference relative to the source root. * * The source root is the directory where the `.xcodeproj` file is located. * * @param buildFile - The file reference to resolve the path of * @returns The absolute path of the file reference, or undefined if the file reference is not found or has no path */ resolveAbsoluteFilePathRelativeToSourceRoot(buildFile) { return path.join(this.baseDir, buildFile.obj.path.replace(/"/g, '')); } /** * Resolves the absolute path of a file reference relative to the SDK root. * * @param buildFile - The file reference to resolve the path of * @returns The absolute path of the file reference, or undefined if the file reference is not found or has no path */ resolveAbsoluteFilePathRelativeToSdkRoot(buildFile) { (0, debug_1.debug)(`Resolving absolute file path relative to SDK root for file reference: ${buildFile.id} with path: ${buildFile.obj.path ?? ''}`); const sdkRoot = macos_system_helper_1.MacOSSystemHelpers.findSDKRootDirectoryPath(); if (!sdkRoot) { (0, debug_1.debug)(`Failed to resolve SDK root directory path`); return undefined; } return path.join(sdkRoot, buildFile.obj.path.replace(/"/g, '')); } /** * Resolves the absolute path of a file reference relative to the developer directory. * * @param buildFile - The file reference to resolve the path of * @returns The absolute path of the file reference, or undefined if the file reference is not found or has no path */ resolveAbsoluteFilePathRelativeToDeveloperDir(buildFile) { (0, debug_1.debug)(`Resolving absolute file path relative to developer directory for file reference: ${buildFile.id} with path: ${buildFile.obj.path ?? ''}`); const developerDir = macos_system_helper_1.MacOSSystemHelpers.findDeveloperDirectoryPath(); if (!developerDir) { (0, debug_1.debug)(`Failed to resolve developer directory path`); return undefined; } return path.join(developerDir, buildFile.obj.path.replace(/"/g, '')); } /** * Resolves the absolute path of a group. * * @param group - The group to resolve the path of * @returns The absolute path of the group, or undefined if the group is not found or has no path */ resolveAbsolutePathOfGroup(group) { (0, debug_1.debug)(`Resolving path of group: ${group.id} with path: ${group.obj.path ?? ''}`); // Group paths are resolved based on the `sourceTree` property // which can have one of the following values: // - '<group>': The group path is relative to the parent group of the group // - 'SOURCE_ROOT': The group path is relative to the source root, i.e. the directory where the Xcode project is located // - 'BUILT_PRODUCTS_DIR': The group path is relative to the built products directory, i.e. the build output directory in derived data // - 'SDKROOT': The group path is relative to the SDK root, i.e. the directory where the SDK is installed // - 'DEVELOPER_DIR': The group path is relative to the developer directory, i.e. the directory where the Xcode command line tools are installed // The default is '<group>' const groupSourceTree = group.obj.sourceTree?.replace(/"/g, '') ?? '<group>'; switch (groupSourceTree) { case '<group>': return this.resolvePathOfGroupRelativeToGroup(group); case 'SOURCE_ROOT': return this.resolvePathOfGroupRelativeToSourceRoot(group); case 'BUILT_PRODUCTS_DIR': return this.resolvePathOfGroupRelativeToBuiltProductsDir(group); case 'SDKROOT': return this.resolvePathOfGroupRelativeToSdkRoot(group); case 'DEVELOPER_DIR': return this.resolvePathOfGroupRelativeToDeveloperDir(group); default: (0, debug_1.debug)(`Unknown source tree '${groupSourceTree}' for group: ${group.id}`); return undefined; } } /** * Resolves the path of a group relative to the parent group. * * @param group - The group to resolve the path of * @returns The path of the group relative to the parent group, or undefined if the group is not found or has no path */ resolvePathOfGroupRelativeToGroup(group) { const parentGroup = this.findParentGroupByChildId(group.id); if (!parentGroup) { (0, debug_1.debug)(`Parent group not found for group: ${group.id}`); // If the parent group is not found, check if the group is the main group // We assume the main group is at the root of the project if (this.isMainGroup(group.id)) { return this.baseDir; } return undefined; } const parentGroupPath = this.resolveAbsolutePathOfGroup(parentGroup); if (!parentGroupPath) { (0, debug_1.debug)(`Failed to resolve path of parent group: ${parentGroup.id}`); return undefined; } const groupPath = group.obj.path?.replace(/"/g, '') ?? ''; if (!groupPath) { (0, debug_1.debug)(`Group path not found for group: ${group.id}`); return undefined; } return path.join(parentGroupPath, groupPath); } /** * Resolves the path of a group relative to the source root. * * The source root is the directory where the `.xcodeproj` file is located. * * @param group - The group to resolve the path of * @returns The path of the group relative to the source root, or undefined if the group is not found or has no path */ resolvePathOfGroupRelativeToSourceRoot(group) { const groupPath = group.obj.path?.replace(/"/g, '') ?? ''; if (!groupPath) { (0, debug_1.debug)(`Group path not found for group: ${group.id}`); return this.baseDir; } return path.join(this.baseDir, groupPath); } /** * Resolves the path of a group relative to the built products directory. * * @param group - The group to resolve the path of * @returns The path of the group relative to the built products directory, or undefined if the group is not found or has no path */ resolvePathOfGroupRelativeToBuiltProductsDir(group) { (0, debug_1.debug)(`Resolving path of group: ${group.id} relative to built products directory`); const builtProductsDir = this.getBuildProductsDirectoryPath(); if (!builtProductsDir) { (0, debug_1.debug)(`Failed to resolve built products directory path`); return undefined; } return path.join(builtProductsDir, group.obj.path?.replace(/"/g, '') ?? ''); } /** * Resolves the path of a group relative to the SDK root. * * The SDK root is the directory where the SDK is installed. * * @param group - The group to resolve the path of * @returns The path of the group relative to the SDK root, or undefined if the group is not found or has no path */ resolvePathOfGroupRelativeToSdkRoot(group) { (0, debug_1.debug)(`Resolving path of group: ${group.id} relative to SDK root`); const sdkRoot = macos_system_helper_1.MacOSSystemHelpers.findSDKRootDirectoryPath(); if (!sdkRoot) { (0, debug_1.debug)(`Failed to resolve SDK root directory path`); return undefined; } return path.join(sdkRoot, group.obj.path?.replace(/"/g, '') ?? ''); } /** * Resolves the path of a group relative to the developer directory. * * The developer directory is the directory where the Xcode command line tools are installed. * * @param group - The group to resolve the path of * @returns The path of the group relative to the developer directory, or undefined if the group is not found or has no path */ resolvePathOfGroupRelativeToDeveloperDir(group) { (0, debug_1.debug)(`Resolving path of group: ${group.id} relative to developer directory`); const developerDir = macos_system_helper_1.MacOSSystemHelpers.findDeveloperDirectoryPath(); if (!developerDir) { (0, debug_1.debug)(`Failed to resolve developer directory path`); return undefined; } return path.join(developerDir, group.obj.path?.replace(/"/g, '') ?? ''); } /** * Resolves the absolute path of a group. * * @param group - The group to resolve the path of * @returns The absolute path of the group, or undefined if the group is not found or has no path */ resolveAbsolutePathOfSynchronizedRootGroup(group) { (0, debug_1.debug)(`Resolving path of synchronized root group: ${group.id} with path: ${group.obj.path ?? ''}`); // Group paths are resolved based on the `sourceTree` property // which can have one of the following values: // - '<group>': The group path is relative to the parent group of the group // - 'SOURCE_ROOT': The group path is relative to the source root, i.e. the directory where the Xcode project is located // - 'BUILT_PRODUCTS_DIR': The group path is relative to the built products directory, i.e. the build output directory in derived data // - 'SDKROOT': The group path is relative to the SDK root, i.e. the directory where the SDK is installed // - 'DEVELOPER_DIR': The group path is relative to the developer directory, i.e. the directory where the Xcode command line tools are installed // The default is '<group>' const groupSourceTree = group.obj.sourceTree?.replace(/"/g, '') ?? '<group>'; switch (groupSourceTree) { case '<group>': return this.resolvePathOfSynchronizedRootGroupRelativeToGroup(group); case 'SOURCE_ROOT': return this.resolvePathOfSynchronizedRootGroupRelativeToSourceRoot(group); case 'BUILT_PRODUCTS_DIR': return this.resolvePathOfSynchronizedRootGroupRelativeToBuiltProductsDir(group); case 'SDKROOT': return this.resolvePathOfSynchronizedRootGroupRelativeToSdkRoot(group); case 'DEVELOPER_DIR': return this.resolvePathOfSynchronizedRootGroupRelativeToDeveloperDir(group); default: (0, debug_1.debug)(`Unknown source tree '${groupSourceTree}' for group: ${group.id}`); return undefined; } } /** * Resolves the path of a group relative to the parent group. * * @param group - The group to resolve the path of * @returns The path of the group relative to the parent group, or undefined if the group is not found or has no path */ resolvePathOfSynchronizedRootGroupRelativeToGroup(group) { const parentGroup = this.findParentGroupByChildId(group.id); if (!parentGroup) { (0, debug_1.debug)(`Parent group not found for group: ${group.id}`); // If the parent group is not found, check if the group is the main group // We assume the main group is at the root of the project if (this.isMainGroup(group.id)) { return this.baseDir; } return undefined; } const parentGroupPath = this.resolveAbsolutePathOfGroup(parentGroup); if (!parentGroupPath) { (0, debug_1.debug)(`Failed to resolve path of parent group: ${parentGroup.id}`); return undefined; } const groupPath = group.obj.path?.replace(/"/g, '') ?? ''; if (!groupPath) { (0, debug_1.debug)(`Group path not found for group: ${group.id}`); return undefined; } return path.join(parentGroupPath, groupPath); } /** * Resolves the path of a group relative to the source root. * * The source root is the directory where the `.xcodeproj` file is located. * * @param group - The group to resolve the path of * @returns The path of the group relative to the source root, or undefined if the group is not found or has no path */ resolvePathOfSynchronizedRootGroupRelativeToSourceRoot(group) { const groupPath = group.obj.path?.replace(/"/g, '') ?? ''; if (!groupPath) { (0, debug_1.debug)(`Group path not found for group: ${group.id}`); return this.baseDir; } return path.join(this.baseDir, groupPath); } /** * Resolves the path of a group relative to the built products directory. * * @param group - The group to resolve the path of * @returns The path of the group relative to the built products directory, or undefined if the group is not found or has no path */ resolvePathOfSynchronizedRootGroupRelativeToBuiltProductsDir(group) { (0, debug_1.debug)(`Resolving path of synchronized root group: ${group.id} relative to built products directory`); const builtProductsDir = this.getBuildProductsDirectoryPath(); if (!builtProductsDir) { (0, debug_1.debug)(`Failed to resolve built products directory path`); return undefined; } return path.join(builtProductsDir, group.obj.path?.replace(/"/g, '') ?? ''); } /** * Resolves the path of a group relative to the SDK root. * * The SDK root is the directory where the SDK is installed. * * @param group - The group to resolve the path of * @returns The path of the group relative to the SDK root, or undefined if the group is not found or has no path */ resolvePathOfSynchronizedRootGroupRelativeToSdkRoot(group) { (0, debug_1.debug)(`Resolving path of group: ${group.id} relative to SDK root`); const sdkRoot = macos_system_helper_1.MacOSSystemHelpers.findSDKRootDirectoryPath(); if (!sdkRoot) { (0, debug_1.debug)(`Failed to resolve SDK root directory path`); return undefined; } return path.join(sdkRoot, group.obj.path?.replace(/"/g, '') ?? ''); } /** * Resolves the path of a group relative to the developer directory. * * The developer directory is the directory where the Xcode command line tools are installed. * * @param group - The group to resolve the path of * @returns The path of the group relative to the developer directory, or undefined if the group is not found or has no path */ resolvePathOfSynchronizedRootGroupRelativeToDeveloperDir(group) { (0, debug_1.debug)(`Resolving path of synchronized root group: ${group.id} relative to developer directory`); const developerDir = macos_system_helper_1.MacOSSystemHelpers.findDeveloperDirectoryPath(); if (!developerDir) { (0, debug_1.debug)(`Failed to resolve developer directory path`); return undefined; } return path.join(developerDir, group.obj.path?.replace(/"/g, '') ?? ''); } /** * Finds all files in the synchronized root groups of a target. * * @param nativeTarget - The target to find the files in * @returns The files in the synchronized root groups of the target, or an empty array if the target is not found or has no synchronized root groups */ findFilesInSynchronizedRootGroups(nativeTarget) { (0, debug_1.debug)(`Finding files in synchronized root groups for target: ${nativeTarget.obj.name}`); const synchronizedRootGroups = nativeTarget.obj.fileSystemSynchronizedGroups ?? []; const result = []; for (const group of synchronizedRootGroups) { const groupObj = this.objects.PBXFileSystemSynchronizedRootGroup?.[group.value]; if (!groupObj || typeof groupObj !== 'object') { (0, debug_1.debug)(`Synchronized root group not found: ${group.value}`); continue; } (0, debug_1.debug)(`Found synchronized root group: ${group.value}`); const files = this.getFilesInSynchronizedRootGroup({ id: group.value, obj: groupObj, }); (0, debug_1.debug)(`Found ${files.length} files in synchronized root group: ${group.value}`); result.push(...files.map((file) => file.path)); } (0, debug_1.debug)(`Found ${result.length} files in synchronized root groups for target: ${nativeTarget.obj.name}`); return result; } getFilesInSynchronizedRootGroup(group) { // Group path is expected to be set, therefore typing is non-nullable. // As the group is loaded from a project file, it is not guaranteed to be set, // therefore we treat it as optional. if (!group.obj.path) { (0, debug_1.debug)(`Group path not found for group: ${group.id}`); return []; } // Resolve the path of the synchronized root group const absoluteGroupPath = this.resolveAbsolutePathOfSynchronizedRootGroup(group); if (!absoluteGroupPath) { (0, debug_1.debug)(`Failed to resolve path of synchronized root group: ${group.id}`); return []; } // Build a list of all exception paths for the group const exceptionSets = this.getExceptionSetsForGroup(group); // Resolve a list of all files in the group const files = this.getAbsoluteFilePathsInDirectoryTree(absoluteGroupPath); // Filter out files that are excluded by the exception sets const filteredFiles = this.filterFilesByExceptionSets(files, exceptionSets); return filteredFiles; } /** * Returns all files in a directory tree. * * @param dirPath - The path of the directory to get the files in * @returns All files in the directory tree, or an empty array if the directory does not exist */ getAbsoluteFilePathsInDirectoryTree(dirPath) { // If the directory does not exist, return an empty array // This can happen if the group is not found in the project if (!fs.existsSync(dirPath)) { return []; } const result = []; const files = fs.readdirSync(dirPath); for (const file of files) { // Ignore hidden files and directories if (file.startsWith('.')) { continue; } const filePath = path.join(dirPath, file); // If the file is a directory, recursively get the files in the directory if (fs.statSync(filePath).isDirectory()) { result.push(...this.getAbsoluteFilePathsInDirectoryTree(filePath)); continue; } // If the file is a file, add it to the result if (fs.statSync(filePath).isFile()) { result.push({ name: file, path: filePath, }); continue; } } return result; } filterFilesByExceptionSets(files, exceptionSets) { // Iterate over all files and filter out files that are excluded by any exception sets return files.filter((file) => { return !exceptionSets.some((exceptionSet) => { const membershipExceptions = exceptionSet.obj.membershipExceptions ?? []; return membershipExceptions.some((path) => { const unescapedPath = path.replace(/"/g, ''); return file.path.includes(unescapedPath); }); }); }); } // ================================ GROUP HELPERS ================================ /** * Returns all groups that are PBXGroup. * * This is a helper method to avoid having to map and filter the groups manually. * * @returns All groups that are PBXGroup, excluding comments and non-object values. */ get groups() { // Map and filter the groups to only include the groups that are PBXGroup return Object.entries(this.objects.PBXGroup ?? {}).reduce((acc, [key, group]) => { if (typeof group !== 'object') { return acc; } return acc.concat([