Categoría: XCode

  • Alternative to .xcodeproj Chaos: Intro to Tuist for iOS Newbies

    Alternative to .xcodeproj Chaos: Intro to Tuist for iOS Newbies

    Tuist is a powerful yet often underutilized tool that can greatly simplify project setup, modularization, and CI workflows. For iOS development beginners, it offers a valuable opportunity to overcome the complexity and fragility of Xcode project files—especially when aiming to build scalable architectures.

    This post serves as a step-by-step guide to help developers get started with Tuist from scratch. While Tuist truly shines in large, complex projects involving multiple developers, this guide is tailored for individuals working on personal apps. The goal is to introduce a different, more structured way of managing project configurations—and to offer a glimpse into how larger, scalable projects are typically handled.

    Install Tuist

    I have installed Tuist by using homebrew, just typing floowing two commands:

    $ brew tap tuist/tuist
    $ brew install --formula tuist
    intallTuist

    That is not the only way. To learn more, please refer to the Install Tuist section in the official documentation.

    Create HelloTuist project

    Navigate to the parent folder where you want to create your iOS app project. The Tuist initialization command will create a new folder containing the necessary project files.

    To initialize a project for the first time, simply run:

    $ tuist init
    tuistinit

    You will be asked a few questions, such as whether the project is being created from scratch or if you’re migrating from an existing .xcodeproj or workspace, which platform you’re targeting, and whether you’re implementing a server.

    Since this is an introduction to Tuist, we will choose «Create a generated project.»

    Let’s take a look at the command that was generated.

    Key Point for understanding this technology, it’s important to know that we’ll be working with two projects.

    The first one, which we’ll refer to in this post as the Tuist project, is the Xcode project responsible for managing the iOS project configuration—this includes target settings, build configurations, library dependencies, and so on.

    The second one is the application project, which is the actual codebase of your app. However, note that this project scaffolder is regenerated each time certain configuration settings change.

    I understand this might sound complicated at first, but it’s a great way to separate application logic from project configuration.

    Lets take a look at the generaded Tuist project by typing following command:

    $ tuist edit

    XCode will be opened up showing to you Tuist project, with some source code, this Swift project configuration source code will be the responsilbe for generating the project scaffoling for your application (or library).

    tuistproj

    This Swift code uses the Tuist project description framework to define a project named «HelloTuist» with two main targets: an iOS app (HelloTuist) and a set of unit tests (HelloTuistTests). The main app target is configured as an iOS application with a unique bundle identifier, sources and resources from specified directories, and a custom launch screen setup in its Info.plist. The test target is set up for iOS unit testing, uses default Info.plist settings, includes test source files, and depends on the main app target for its operation.

    Lets continue with the workflow, next step is generating application project by typing:

    $ tuist generate --no-open
    tuistgenerate

    By using this command, we have created the final .xcodeproj application project. The --no-open optional parameter was specified to prevent Xcode from opening the project automatically. We will open the project manually from the command line.

    $ xed .
    xcode

    The default project folder structure is different from what we’re accustomed to when coming from classical Xcode project creation. However, it’s something we can get used to, and it’s also configurable through the Tuist project.

    Deploying the app on the simulator is useful just to verify that everything works as expect

    Simulator Screenshot - iPhone 16 Pro - 2025-05-31 at 16.55.34

    From now on, focus only on your Swift application’s source code files. It’s important to remember that the .xcodeproj file is no longer part of your base code — it is autogenerated. Whenever you need to change project configurations, edit the Tuist project and run tuist generate.

    Setup application version

    The application version and build number are two parameters—MARKETING_VERSION and CURRENT_PROJECT_VERSION—managed in the project’s build settings. To set these values, open the Tuist project using the command line.

    $ tuist edit

    And set following changes (On Tuist prject):

    public extension Project {
        static let settings: Settings = {
            let baseConfiguration: SettingsDictionary = [
                "MARKETING_VERSION": "1.2.3",
                "CURRENT_PROJECT_VERSION": "42"
            ]
            let releaseConfiguration = baseConfiguration
            return Settings.settings(base: baseConfiguration, debug: baseConfiguration, release: releaseConfiguration)
        }()
    }
    
    public extension Target {
        static let settings: Settings = {
            let baseConfiguration: SettingsDictionary = [:]
            var releaseConfig = baseConfiguration
            return Settings.settings(base: baseConfiguration, debug: baseConfiguration, release: releaseConfig)
        }()
    }

    We aim to have a unique version value across all targets. To achieve this, we’ve set the version value at the project level and assigned an empty dictionary ([:]) in the target settings to inherit values from the project settings.

    Finally, configure the settings for both the project and target structures:

    let project = Project(
        name: "HelloTuist",
        settings: Project.settings,
        targets: [
            .target(
                name: "HelloTuist",
                destinations: .iOS,
                product: .app,
                bundleId: "io.tuist.HelloTuist",
                infoPlist: .extendingDefault(with: [
                    "CFBundleDisplayName": "Tuist App",
                    "CFBundleShortVersionString": "$(MARKETING_VERSION)",
                    "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)"
                ]),
                sources: ["HelloTuist/Sources/**"],
                resources: ["HelloTuist/Resources/**"],
                dependencies: [],
                settings: Target.settings
            ),
            .target(
                name: "HelloTuistTests",
                destinations: .iOS,
                product: .unitTests,
                bundleId: "io.tuist.HelloTuistTests",
                infoPlist: .default,
                sources: ["HelloTuist/Tests/**"],
                resources: [],
                dependencies: [.target(name: "HelloTuist")]
            ),
        ]
    )

    The settings parameter is defined both at the project and target levels. Additionally, MARKETING_VERSION is linked to CFBundleShortVersionString in the Info.plist. As a result, the app will retrieve the version value from CFBundleShortVersionString.

    Once the Tuist project is set up, the application project should be regenerated.

    $ tuist generate --no-open
    rege

    And open application project adding following changes con the view:

    struct ContentView: View {
        var appVersion: String {
            let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
            let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
            return "Version \(version) (\(build))"
        }
        
        var body: some View {
            NavigationView {
                List {
                    Section(header: Text("Information")) {
                        HStack {
                            Label("App versusion", systemImage: "number")
                            Spacer()
                            Text(appVersion)
                                .foregroundColor(.secondary)
                        }
                    }
                }
                .navigationTitle("Hello Tuist!")
                .navigationBarTitleDisplayMode(.inline)
            }
        }
    }

    Deploy app for checking that version is properly presented.

    Simulator Screenshot - iPhone 16 Pro - 2025-05-31 at 17.34.41

    As you have observed, the operations of setting the app version in the project configuration and presenting the version are decoupled. Additionally, changes made to the project are now easier to review.

    changes

    Adding library dependencies

    Another common project configuration is the addition of third-party libraries. I’m neither for nor against them—this is a religious debate I prefer not to engage in.

    For demonstration purposes, we will integrate the Kingfisher library to fetch a remote image from the internet and display it in the application’s view.

    Again, open back Tuist project by typing ‘tuist edit’ and set the library url on ‘Tuist/Package.swift’ file:

    let package = Package(
        name: "tuistHello",
        dependencies: [
            // Add your own dependencies here:
            .package(url: "https://github.com/onevcat/Kingfisher", .upToNextMajor(from: "8.3.2")),
            // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies
        ]
    )

    Also set dependencies attribute array:

    let project = Project(
        name: "HelloTuist",
        settings: Project.settings,
        targets: [
            .target(
                name: "HelloTuist",
                destinations: .iOS,
                product: .app,
                bundleId: "io.tuist.HelloTuist",
                infoPlist: .extendingDefault(with: [
                    "CFBundleDisplayName": "Tuist App",
                    "CFBundleShortVersionString": "$(MARKETING_VERSION)",
                    "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)"
                ]),
                sources: ["HelloTuist/Sources/**"],
                resources: ["HelloTuist/Resources/**"],
                dependencies: [
                    .external(name: "Kingfisher")
                ],
                settings: Target.settings
            ),
            .target(
                name: "HelloTuistTests",
                destinations: .iOS,
                product: .unitTests,
                bundleId: "io.tuist.HelloTuistTests",
                infoPlist: .default,
                sources: ["HelloTuist/Tests/**"],
                resources: [],
                dependencies: [.target(name: "HelloTuist")]
            ),
        ]
    )

    Following workflow, re-generate application project by typing ‘tuist generate’.

    tuistgenfa

    … but this time something is going wrong. And is because when an external library is bein integrated iun a Tuist project we have to install it first. By typing following command app is downloaded and installed:

    $ tuist install

    After ‘tuist install’ execute ‘tuist generate –no-open’ for finally generating application project. Add following content to view:

    import Kingfisher
    import SwiftUI
    
    struct ContentView: View {
       ...
        var body: some View {
            NavigationView {
                List {
                    Section(header: Text("Information")) {
                        HStack {
                            Label("App versusion", systemImage: "number")
                            Spacer()
                            Text(appVersion)
                                .foregroundColor(.secondary)
                        }
                    }
                    KFImage(URL(string: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQyfYoLcb2WNoStJH01TT2TLAf_JbD_FhIJng&s")!)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 300, height: 300)
                        .cornerRadius(12)
                        .shadow(radius: 5)
                        .padding()
                }
                .navigationTitle("Hello Tuist!")
                .navigationBarTitleDisplayMode(.inline)
            }
        }
    }

    Finally deploy app for checking that image is properly fetched:

    Simulator Screenshot - iPhone 16 Pro - 2025-05-31 at 18.16.20

    Finally deploy app for checking that image is properly fetched.

    Conclusions

    The first time I heard about this technology, I was immediately drawn to it. However, I must confess that I struggled at first to understand the workflow. That’s exactly why I decided to write this post.

    While Tuist might be overkill for small projects, it becomes essential for large ones—especially when you consider the number of lines of code and developers involved. After all, every big project started out small.

    Another major advantage is that changes to the project setup are decoupled from changes to the application itself. This makes them easier to review—much like comparing SwiftUI code to .xib files or storyboards.

    Who knows? Maybe Apple will one day release its own version of Tuist, just like it did with Combine and Swift Package Manager (SPM).

    You can find source code used for writing this post in following repository

    References

  • Tired of Repeating Configs in Every Target?

    Tired of Repeating Configs in Every Target?

    Centralizing configuration parameters across multiple iOS targets is a valuable approach, especially in larger or modularized projects. Maintaining separate settings for each target often leads to duplication, inconsistency, and errors. Developers frequently struggle to keep build settings, API endpoints, feature flags, and environment variables in sync across targets such as staging, production, or app extensions.

    By demonstrating how to structure and manage these settings in a clean, scalable way—using tools like xcconfig files or centralized Swift structs—you can enhance maintainability, reduce bugs, and promote best practices in professional iOS development.

    In this post, we’ll walk through an example of centralizing the project and build version across multiple targets.

    Note: The goal here is not to convince you to centralize all configurations, but to show you how to do it effectively if your project requires it.

    Multiple version values

    One common problem when having more than one target app is managing multiple version values across different targets for the same app version.

    In this case, MARKETING_VERSION and CURRENT_PROJECT_VERSION are defined in three places: the project build settings and each target’s build settings. We want to define them at the project level, and have each target inherit these values from the project.

    To do this, select the CenterXCodeConfigs target:

    And replace 1 by $(CURRENT_PROJECT_VERSION), and also

    1.0 by $(MARKETING_VERSION). Switch to project build settings:

    Now, fill in the current project version and marketing version with the desired values, then switch back to the CenterXCodeConfigs target’s build settings.

    Voilà! Values are now inherited from the project settings. Repeat the same operation for the AlternativeApp target.

    Conclusions

    In this post, I presented how to centralize common settings values across all targets. You can find source code used for writing this post in following repository

  • Firebase Authentication in Your iOS App

    Firebase Authentication in Your iOS App

    User authentication is often a cumbersome task that becomes essential as an application grows more robust. Firebase Authentication simplifies this process by handling authentication for you. It supports several authentication methods, but for the purpose of this post, we will focus on email and password authentication.

    Firebase console

    The first step is to create a dashboard to manage your app’s Firebase capabilities. To do this, open the Firebase console and create a new project. Use the name of your app as the project name for better organization and clarity. For the purposes of this post, I will not enable analytics. With these steps completed, your project should be ready to use.

    Later on, we will revisit the setup-specific issues for your iOS app.

    Creating iOS App scaffolder

    Let’s set Firebase aside for a moment and create a blank application. The only important detail in this process is to pay attention to the Bundle Identifier, as we’ll need it in the upcoming steps.

    Connect Firebase to your app

    Return to Firebase to add it to your app, and select the iOS option

    This is the moment to enter your app’s iOS Bundle Identifier.

    The next step is to download the configuration file and incorporate it into your project.

    The final step is incorporating the Firebase SDK using Swift Package Manager (SPM). To do this, select your project, go to Package Dependencies, and click Add Package Dependency.

    Enter the GitHub repository URL provided in Step 3 of the Firebase configuration into the search input box.

    Don’t forget to add FirebaseAuth to your target application.

    Continue through the Firebase configuration steps, and it will eventually provide the code you need to connect your app to Firebase.

    Incorporate this code into your iOS app project, then build and run the project to ensure everything is working properly.

    Implement authentication

    Get back to Firebase console to set up Authentication

    As you can see, there are many authentication methods, but for the purpose of this post, we will only work with the email and password method.

    As you can see, there are many authentication methods, but for the purpose of this post, we will only focus on the email and password method.

    For this post, I have implemented basic views for user registration, login, logout, and password recovery. All authentication functionality has been wrapped into a manager called AuthenticatorManager. The code is very basic but fully functional and compliant with Swift 6.0.

    Don’t worry if I go too fast; the link to the code repository is at the end of this post. You’ll see that the code is very easy to read. Simply run the project, register an account, and log in.

    You can find the project code at following repository.

    Conclusions

    Firebase provides a fast and efficient way to handle user authentication as your app scales.

  • keepin’ secrets on your pillow

    keepin’ secrets on your pillow

    The aim of this post is to provide one of the many available methods for keeping secrets, such as passwords or API keys, out of your base code while still ensuring they are accessible during the development phase.

    .env file

    One common approach is to create a text file, usually named by convention as .env, although you are free to name it as you prefer. This file will contain the secrets of your project.

    This is a keep-it-out-of-version-control

    This is a medicine; keep out of reach of children version control system. So be sure that it is included in .gitignore.

    Commit and push changes asap:

    An important consideration is where to place the folder. It must be located in the same root directory where the source code begins.

    If you have already committed and pushed the changes, anyone with access to your repository could potentially access that data. You have two alternatives:

    • Use git rebase: With git rebase, you can modify your commit history, allowing you to remove the problematic commit. This is a cleaner approach but requires careful handling to avoid conflicts.
    • Create a new repository: This option will result in the loss of your commit history but can be simpler if preserving history is not essential.

     

    Additionally, remember that your .env file is only stored locally on your machine. Ensure you keep it secure to prevent sensitive information from being exposed.

    Lets play with XCode

    Create a blank app project in XCode and be sure that following settings are set:

    Swift Language version to Swift 6 and …

    Strict Concurrency Checking is set to Complete, ensuring that our code, in addition to being secure and preventing disclosure of sensitive information, will also be free from data races. Finally, create a new file with the following component.

    import Foundation
    
    @globalActor
    actor GlobalManager {
        static var shared = GlobalManager()
    }
    
    @GlobalManager final class Env {
        static let env: [String: String] = loadEnvVariables()
        static let filename = ".env"
        private init() {
        }
    
        static func fetch(key: String) -> String? {
            env[key]
        }
    
        static func loadEnvVariables() -> [String: String] {
            var envVariables: [String: String] = [:]
            guard let path = Bundle.main.path(forResource: filename, ofType: nil) else {
                return [:]
            }
            do {
                let content = try String(contentsOfFile: path, encoding: .utf8)
                let lines = content.components(separatedBy: .newlines)
                for line in lines {
                    let components = line.components(separatedBy: "=")
                    if components.count == 2 {
                        let key = components[0].trimmingCharacters(in: .whitespaces)
                        let value = components[1].trimmingCharacters(in: .whitespaces)
                        envVariables[key] = value
                    }
                }
            } catch {
                print("Error reading .env: \(error)")
            }
            return envVariables
        }
    }

    Finally, use the Env component to fetch secret data from the .env file.

    struct ContentView: View {
        @State private var apiKey: String?
        var body: some View {
            VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text("API_KEY:\(apiKey ?? "Not set")")
            }
            .padding()
            .onAppear {
                Task {
                    apiKey = await Env.fetch(key: "API_KEY")
                }
            }
        }
    }

    This is the default ContentView generated in a blank project. We have added content using the .onAppear() modifier to fetch the secret, which is displayed with a Text view. Run the project, and the final result is as follows:

    Screenshot

    Conclusions

    This is a safe way to keep your project secrets away from indiscreet eyes. However, your secret file is only stored locally in your (or the developer team’s) project folder. On this repository is the source code that I have used for writing this post.

  • Streamlining Your Xcode Projects with GitHub Actions

    Streamlining Your Xcode Projects with GitHub Actions

    Having good practices is one of the key points for successfully steering your project to completion, especially when working as part of a team. In this post, I will explain how to implement these tasks in a CI/CD environment such as GitHub.

    First, we will set up essential tasks like unit testing and linting locally, and then apply these tasks as requirements for integration approvals.

    Executing unit test through command line console

    At this point, we assume that the unit test target is properly configured in your project. This section is important because we will need to use the command in the future.»

    xcodebuild is a command-line tool provided by Apple as part of Xcode, it allows developers to build and manage Xcode projects and workspaces from the terminal, providing flexibility for automating tasks, running continuous integration (CI) pipelines, and scripting.

    Simply execute the command to validate that everything is working correctly.

    Linting your code

    Linting your code not only improves quality, prevents errors, and increases efficiency, but it also facilitates team collaboration by reducing time spent on code reviews. Additionally, linting tools can be integrated into CI pipelines to ensure that checks are part of the build and deployment process.

    The tool we will use for linting is SwiftLint. Here, you will find information on how to install it on your system. Once it is properly installed on your system:

    Go to project root folder and create file .swiftlint.yml, this is default configuration, you can check following link to know more about the defined rules.

    disabled_rules:
    - trailing_whitespace
    opt_in_rules:
    - empty_count
    - empty_string
    excluded:
    - Carthage
    - Pods
    - SwiftLint/Common/3rdPartyLib
    line_length:
        warning: 150
        error: 200
        ignores_function_declarations: true
        ignores_comments: true
        ignores_urls: true
    function_body_length:
        warning: 300
        error: 500
    function_parameter_count:
        warning: 6
        error: 8
    type_body_length:
        warning: 300
        error: 500
    file_length:
        warning: 1000
        error: 1500
        ignore_comment_only_lines: true
    cyclomatic_complexity:
        warning: 15
        error: 25
    reporter: "xcode"
    

    Now, let’s integrate this in Xcode. Select your target, go to Build Phases, click the plus (+) button, and choose ‘New Run Script Phase’.

    Rename the script name to ‘swiftlint’ for readability, and make sure to uncheck ‘Based on…’ and ‘Show environment…’.

    Paste the following script.

    echo ">>>>>>>>>>>>>>>>>>>>>>>> SWIFTLINT (BEGIN) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"
    if [[ "$(uname -m)" == arm64 ]]; then
        export PATH="/opt/homebrew/bin:$PATH"
    fi
    
    if which swiftlint > /dev/null; then
      swiftlint
    else
      echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
    fi
    echo "<<<<<<<<<<<<<<<<<<<<<<<<< SWIFTLINT (END) <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"

    Select the target and build (Cmd+B). If you review the build log, you will see a new warnings triggered.

    GitHub actions

    GitHub Actions is a powerful CI/CD  platform integrated within GitHub, allowing developers to automate, customize, and streamline their software workflows directly from their repositories. It uses YAML configuration files to define tasks or workflows that run in response to events, such as pushing code, opening pull requests, or setting schedules. With its flexibility, developers can automate building, testing or deploying applications.

    Workflow for executing unit test

    At this point, we assume that we are in the root folder of a repository cloned from GitHub. Navigate to (or create) the following folder: ./github/workflows. In that folder, we will place our first GitHub Action for executing the unit tests. In my case, it was a file called UTest.yml with the following content:

    name: utests-workflow
    
    on:
      pull_request:
        branches: [main, develop]
    jobs:
      utests-job:
        runs-on: macos-latest
    
        steps:
          - name: Check out the repository
            uses: actions/checkout@v4
    
          - name: Set to XCode 16.0
            uses: maxim-lobanov/setup-xcode@v1
            with:
               xcode-version: '16.0'
    
          - name: Execute Unit tessts (iOS target)
            run: xcodebuild test -scheme 'EMOM timers' -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest'
    
          - name: Execute Unit tessts (AW target)
            run: xcodebuild test -scheme 'EMOM timers Watch App' -destination 'platform=watchOS Simulator,name=Apple Watch Series 10 (42mm),OS=latest'
    

    The first tag is name, which contains the given name for the workflow. Next, the on tag defines which event can trigger the workflow to run. In this case, we are interested in executing the workflow when a pull request targets the main or develop branch (for available events, please review the documentation).

    Finally, we find the jobs section, which describes the tasks we want to execute. utest-job is the designated name for this job, and the environment where it will be executed is specified as macos-latest.

    Next, we find the steps, which outline the sequence of actions to be executed. The first step is to check out the repository. In this step, we specify a shell command, but instead, we see an action being referenced. An action is a piece of code created by the community to perform specific tasks. It is highly likely that someone has already written the action you need, so be sure to review GitHub Actions or GitHub Actions Marketplace.  The checkout action was defined in the GitHub Actions   repositoty, and we can see that its popularity is good, so let’s give it a try.

    The second step is to set the Xcode version to one that is compatible with your project. My project is currently working with Xcode 16.0, so we need to set it to that version. I found this action in the GitHub Action marketplace.

    The final two steps involve executing unit tests for each target. Now, we can commit and push our changes.

    Create a pull request on GitHub for your project, and at the end, you should see that the workflow is being executed.

    After few minutes…

    I aim you to force fail some unit test and push changes. Did allow you to integrate branch?

    Workflow for linting

    We are going to create a second workflow to verify that static syntax analysis (linting) is correct. For this purpose, we have created the following .yml GitHub Action workflow script:

    name: lint
    
    # Especifica en qué ramas se ejecutará el workflow
    on:
      pull_request:
        branches: [main, develop]
    jobs:
      lint:
        runs-on: macos-latest
        steps:
          - name: Checkout code
            uses: actions/checkout@v4
    
          - name: Install SwiftLint
            run: brew install swiftlint
    
          - name: Run SwiftLint
            run: swiftlint

    In this workflow, the name and job refer to the linting process, with the only differences being in the last two steps.

    The first step, as in the previous workflow, is to check out the branch. The next step is to install SwiftLint via Homebrew, and the final step is to run SwiftLint. In this case, we will deliberately trigger a linting error.

    Once we commit and push the change, let’s proceed to review the pull request. The lint workflow has been executed, and a linting error has been triggered. However, it is still possible to merge the pull request, which is what we want to avoid. In the next section, we’ll address this issue.

    Setup branch rules

    Now it’s time to block any merges on the develop branch. Go to your repository settings.

    In the Branches section, click Add rule under Branch protection rules and select Add a classic branch protection rule.

    Enter the branch name where the rule applies. In this case, it is develop. Check «Require status checks to pass before merging» and «Require branch to be up to date before merging». In the search box below, type the workflow name. In this case, we want the unit tests and linting to succeed before proceeding with the pull request.

    When we return to the pull request page (and possibly refresh), we will see that we are not allowed to merge. This was our goal: to block any pull request that does not meet the minimum quality requirements.

    This rule has been applied to the development branch. I leave it to the reader to apply the same rule to the main branch.

    Conclusions

    By integrating GitHub Actions into our team development process, we can automate tasks that help us avoid increasing technical debt.

    Related links