Command-Line Tools in Objective-C

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Objective-C is the oldest way to write a native command-line tool on macOS that uses the Cocoa frameworks. A tool is an ordinary C program whose main function creates an autorelease pool, calls Foundation classes instead of (or next to) the C library, and returns an exit status. Foundation supplies the pieces a command-line program needs: the arguments and environment (NSProcessInfo), a -key value parser (NSUserDefaults), the standard streams (NSFileHandle), subprocesses (NSTask) and an event loop (NSRunLoop, with Grand Central Dispatch underneath). This page builds the running Bookshelf scenario with them: a shelf command-line tool and a shelfd background daemon.

The page was written against Apple clang 21 and the Foundation framework of macOS 27, with ARC (automatic reference counting). Every Objective-C snippet on it was compiled with clang -fobjc-arc -Wall -Wextra and run on macOS, and the outputs shown are the real ones. The GNUstep makefile near the end was not built while the page was written, because no GNUstep installation was available. New projects that do not need Objective-C should look at Command-Line Tools in Swift, which covers the same ground with swift-argument-parser. The language itself is documented in the Objective-C Reference; the POSIX mechanisms that Foundation wraps (fork, exec, sigaction, descriptors) are on POSIX Processes, Signals & I/O, and the design rules for the output of a tool (streams, exit codes, machine-readable output) are on Streams & Exit Codes.

Platforms and what to expect

Foundation is a macOS (and iOS, tvOS, watchOS, visionOS) framework. Apple documents it only for its own platforms, so the table separates what is Apple-supported from what is a community port.

Platform How Objective-C command-line tools are built Status

macOS

Xcode (Command Line Tool template) or clang from the Command Line Tools package; Foundation is a system framework

Supported by Apple. The tool is a Mach-O executable.

Linux

GNUstep (gnustep-base, gnustep-make) with clang and the GNUstep Objective-C runtime

Community project. It implements the OpenStep and Cocoa APIs, so Apple’s documentation describes the intent but not every method exists, behaves identically or is available in the version you installed.

Windows

GNUstep through MSYS2 / MinGW, or clang with the GNUstep runtime

Community project, with the same caveat about API coverage. The Win32 console details on Windows Console & Processes apply to the resulting program.

Apple does not publish an article about the Xcode Command Line Tool template, so the template is described below from what Xcode generates rather than from a dedicated documentation page.

The Xcode template and the clang command line

In Xcode, File > New > Project, then the macOS tab and the Command Line Tool template. The Language popup lets you pick Objective-C instead of Swift, C or C++. Xcode generates a single main.m that links Foundation, with a main function shaped like the one below, and a scheme that runs the tool inside Xcode’s console. The Xcode side of projects, schemes and building from the command line with xcodebuild is on Xcode Projects & Previews and CI/CD, Xcode Cloud & the Command Line.

A tool does not need Xcode. The Command Line Tools package (xcode-select --install) provides clang and the macOS SDK, which is enough for everything on this page.

$ clang -fobjc-arc -Wall -Wextra -framework Foundation -o shelf shelf.m
Flag Meaning

-fobjc-arc

Compile with automatic reference counting. Without it the file uses manual retain / release, which is covered on Manual Retain-Release. ARC is the default in Xcode’s template and should be used for new code.

-framework Foundation

Link the Foundation framework. A tool that also uses Core Foundation, Security or AppKit names those frameworks too.

-Wall -Wextra

Turn on the common warnings. Deprecated Foundation methods (for example -[NSTask launch]) show up here.

-arch arm64 -arch x86_64

Build a universal binary in one command; see Universal binaries, signing and distribution.

-mmacosx-version-min=15.0

Set the lowest macOS version the binary may run on (use the version your users have). Newer API calls then produce availability warnings instead of runtime crashes.

The snippets below use NSFileHandle -writeData:error: and NSTask -launchAndReturnError:, which are the error-reporting forms of the older exception-throwing methods. They require macOS 10.15 or later.

main, @autoreleasepool and NSProcessInfo

Foundation-based programs keep the C signature int main(int argc, const char *argv[]), so argc and argv are available exactly as in C. The body is wrapped in @autoreleasepool: objects returned autoreleased by Foundation methods are released when the block ends, and a long-running loop that creates many temporary objects should wrap each iteration in its own pool so memory stays flat. The arguments and environment are also available as objects through NSProcessInfo.processInfo.

#import <Foundation/Foundation.h>

int main(int argc, const char *argv[]) {
    @autoreleasepool {
        NSProcessInfo *info = NSProcessInfo.processInfo;
        NSArray<NSString *> *args = info.arguments;      // args[0] is the program name
        NSDictionary<NSString *, NSString *> *env = info.environment;

        NSString *home = env[@"SHELF_HOME"] ?: @"(unset)";
        printf("process name : %s\n", info.processName.UTF8String);
        printf("argc / count : %d / %lu\n", argc, (unsigned long)args.count);
        printf("SHELF_HOME   : %s\n", home.UTF8String);
        for (NSUInteger i = 1; i < args.count; i++) {
            printf("arg[%lu]      : %s\n", (unsigned long)i, args[i].UTF8String);
        }
        if (args.count < 2) {
            fprintf(stderr, "usage: %s <command> [options]\n", argv[0]);
            return 64;  // EX_USAGE from <sysexits.h>
        }
    }
    return 0;
}
$ SHELF_HOME=/srv/shelf ./args list --all "Dune Messiah"
process name : args
argc / count : 4 / 4
SHELF_HOME   : /srv/shelf
arg[1]      : list
arg[2]      : --all
arg[3]      : Dune Messiah
$ ./args
usage: ./args <command> [options]
$ echo $?
64

Things to know about these APIs:

  • NSProcessInfo.arguments contains the same strings as argv, as NSString objects. environment returns a new dictionary on each call; in a test, a variable added with setenv was visible in a later call. Pass changed variables to children explicitly through NSTask.environment (see Running subprocesses with NSTask).

  • processName is the file name of the executable, and is the name NSLog prints.

  • Returning from main ends the process with that status, like in C. exit (and abort) skips the end of the autorelease pool, which is harmless at process exit.

  • Foundation also offers NSProcessInfo.processIdentifier, hostName, operatingSystemVersion, physicalMemory and activeProcessorCount, which are useful for diagnostics output such as shelf --version -v.

Source: Apple — ProcessInfo and Apple — NSAutoreleasePool. How a C main receives its arguments and what an exit status means in the context of the whole language is on C program structure.

The NSUserDefaults argument domain

NSUserDefaults is Cocoa’s preferences database, and it has a feature unique to the platform: arguments of the form -key value on the command line are searched first when a value is looked up. Apple calls this the argument domain, which sits ahead of the application’s saved preferences, the global domain and the registered defaults in the search list. The tool does not parse anything: it asks NSUserDefaults for limit, and the command line wins over the preferences file, which in turn wins over the value registered in code. This is how many Cocoa developer tools take options such as -NSDocumentRevisionsDebugMode YES.

#import <Foundation/Foundation.h>

int main(void) {
    @autoreleasepool {
        NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults;
        // Defaults used when neither the command line nor a preferences file sets a key.
        [defaults registerDefaults:@{ @"limit": @20, @"title": @"", @"verbose": @NO }];

        NSInteger limit = [defaults integerForKey:@"limit"];
        NSString *title = [defaults stringForKey:@"title"];
        BOOL verbose = [defaults boolForKey:@"verbose"];
        NSArray *tags = [defaults arrayForKey:@"tags"];

        printf("limit=%ld title='%s' verbose=%s tags=%s\n", (long)limit, title.UTF8String,
               verbose ? "YES" : "NO", tags ? tags.description.UTF8String : "(none)");
    }
    return 0;
}
$ ./defaults
limit=20 title='' verbose=NO tags=(none)
$ ./defaults -limit 5 -title "Dune Messiah" -verbose YES
limit=5 title='Dune Messiah' verbose=YES tags=(none)
$ ./defaults list -limit 7 extra
limit=7 title='' verbose=NO tags=(none)
$ ./defaults -tags '(sci-fi,classic)'
limit=20 title='' verbose=NO tags=(
    "sci-fi",
    classic
)

Behaviours observed while running this program on macOS, none of which comes from a dedicated guarantee in Apple’s documentation:

  • The pairs may appear anywhere on the line, after positional words such as list; the argument domain looks at every -key value pair it can find.

  • The value is read as a property list fragment, so (sci-fi,classic) becomes an array. Typed accessors convert: boolForKey: accepts YES, and integerForKey: parses digits.

  • There is no --limit, no -n 5 short form, no --help, no validation and no error for an unknown key. A key that is missing or does not convert silently yields the registered default.

  • A value that begins with a hyphen is not taken as the value: -limit -3 printed the default of 20. A key with no value (-verbose as the last word) also leaves the default.

  • Keys that Cocoa itself reads are also set from the command line. -AppleLanguages '(fr)' changed NSLocale.preferredLanguages for that run from the system language to fr, which is useful for testing localized output and a surprise if a user happens to pass the same key.

The argument domain is a good fit for developer tools, test harnesses and Xcode scheme arguments, where users already know the -key value convention from Apple’s own tools. It is not a fit for a tool that follows the POSIX utility syntax guidelines, or that needs help text, subcommands or error messages; use getopt_long (below) or swift-argument-parser for that.

Mixing in getopt_long

Because Objective-C is C, the POSIX and GNU option parsers are available without any wrapper: getopt and getopt_long come from <getopt.h>, and the BSD <sysexits.h> header names the conventional exit codes (EX_USAGE is 64, EX_DATAERR is 65, EX_IOERR is 74). The listing is a complete shelf with options and help, using Foundation only where it helps (NSString for the title).

#import <Foundation/Foundation.h>
#include <getopt.h>
#include <sysexits.h>

static void usage(FILE *to) {
    fputs("usage: shelf list [-n|--limit N] [-t|--title TEXT] [-v|--verbose]\n"
          "       shelf --help | --version\n", to);
}

int main(int argc, char *argv[]) {
    @autoreleasepool {
        static const struct option longopts[] = {
            { "limit",   required_argument, NULL, 'n' },
            { "title",   required_argument, NULL, 't' },
            { "verbose", no_argument,       NULL, 'v' },
            { "help",    no_argument,       NULL, 'h' },
            { "version", no_argument,       NULL, 'V' },
            { NULL, 0, NULL, 0 }
        };

        long limit = 20;
        NSString *title = nil;
        BOOL verbose = NO;
        int c;
        while ((c = getopt_long(argc, argv, "n:t:vhV", longopts, NULL)) != -1) {
            switch (c) {
                case 'n': {
                    char *end = NULL;
                    limit = strtol(optarg, &end, 10);
                    if (*optarg == '\0' || *end != '\0' || limit < 1) {
                        fprintf(stderr, "shelf: invalid --limit '%s'\n", optarg);
                        return EX_USAGE;
                    }
                    break;
                }
                case 't': title = [NSString stringWithUTF8String:optarg]; break;
                case 'v': verbose = YES; break;
                case 'h': usage(stdout); return EX_OK;
                case 'V': puts("shelf 1.0.0"); return EX_OK;
                default:  usage(stderr); return EX_USAGE;   // getopt_long already printed the reason
            }
        }

        if (optind >= argc || strcmp(argv[optind], "list") != 0) {
            usage(stderr);
            return EX_USAGE;
        }
        printf("list: limit=%ld title=%s verbose=%s\n", limit, title ? title.UTF8String : "(any)",
               verbose ? "yes" : "no");
    }
    return EX_OK;
}
$ ./shelf list --limit 5 --title Dune -v
list: limit=5 title=Dune verbose=yes
$ ./shelf --limit 0 list; echo $?
shelf: invalid --limit '0'
64
$ ./shelf --bogus; echo $?
shelf: unrecognized option `--bogus'
usage: shelf list [-n|--limit N] [-t|--title TEXT] [-v|--verbose]
       shelf --help | --version
64

Pitfalls when choosing between the two parsers:

  • Use one scheme per tool. getopt_long reads -limit 5 as the short options -l -i -m -i -t, and NSUserDefaults reads -n 5 as a key named n. A tool that uses getopt_long and also touches NSUserDefaults.standardUserDefaults (for example through a framework) will have unrelated -key value pairs on its command line interpreted by Cocoa without anybody asking.

  • getopt_long accepts unambiguous abbreviations of long options: shelf list --lim 3 was accepted as --limit 3. That is convenient interactively and a compatibility trap for scripts, because adding a later option such as --limits makes the old abbreviation ambiguous.

  • The options are processed on the C argv. Convert to NSString only after validation, and note that optarg is a C string in the user’s bytes: stringWithUTF8String: returns nil for invalid UTF-8.

  • macOS getopt keeps its state in global variables such as optind. Reset it with optreset = 1; optind = 1; if a program parses a second argument vector; the GNU C library uses a different convention (optind = 0), which is one of the differences between the platforms that GNUstep builds on Linux have to account for.

Source: POSIX — getopt. getopt_long is a GNU extension that macOS and the BSDs also provide; the C-specific behaviour, argp and error reporting are on Command-Line Programs in C.

Standard streams with NSFileHandle

NSFileHandle wraps a file descriptor and has factory methods for the three standard streams. The error-returning methods (readDataToEndOfFileAndReturnError:, writeData:error:) report failures as an NSError rather than raising an exception. The program below numbers the lines that arrive on standard input, writes the result to standard output and diagnostics to standard error, and uses sysexits.h-style exit statuses for bad data and I/O failures.

#import <Foundation/Foundation.h>

int main(void) {
    @autoreleasepool {
        NSFileHandle *in  = NSFileHandle.fileHandleWithStandardInput;
        NSFileHandle *out = NSFileHandle.fileHandleWithStandardOutput;
        NSFileHandle *err = NSFileHandle.fileHandleWithStandardError;

        // Read all of stdin (until EOF), one title per line.
        NSError *error = nil;
        NSData *data = [in readDataToEndOfFileAndReturnError:&error];
        if (data == nil) {
            NSString *msg = [NSString stringWithFormat:@"shelf: cannot read stdin: %@\n", error.localizedDescription];
            [err writeData:[msg dataUsingEncoding:NSUTF8StringEncoding] error:NULL];
            return 74;  // EX_IOERR
        }
        NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
        if (text == nil) {
            [err writeData:[@"shelf: input is not valid UTF-8\n" dataUsingEncoding:NSUTF8StringEncoding] error:NULL];
            return 65;  // EX_DATAERR
        }

        NSUInteger n = 0;
        for (NSString *line in [text componentsSeparatedByString:@"\n"]) {
            if (line.length == 0) continue;
            n++;
            NSString *row = [NSString stringWithFormat:@"%2lu. %@\n", (unsigned long)n, line];
            if (![out writeData:[row dataUsingEncoding:NSUTF8StringEncoding] error:&error]) {
                return 74;  // e.g. EPIPE when the reader has gone away
            }
        }
    }
    return 0;
}
$ printf 'Dune\nHyperion\n' | ./fh
 1. Dune
 2. Hyperion
$ printf '\xff\n' | ./fh; echo $?
shelf: input is not valid UTF-8
65

Pitfalls specific to this API:

  • fileHandleWithStandardInput and its siblings return shared handles for descriptors 0, 1 and 2. Do not close them unless the program really intends to close the stream.

  • readDataToEndOfFileAndReturnError: loads everything into memory. For large or endless input (tail -f, a pipe from a long-running process) read in pieces with readDataUpToLength:error: or use the asynchronous readabilityHandler property together with a run loop.

  • SIGPIPE still applies. When the reader of the pipe exits early, the default action of SIGPIPE ends the process before writeData:error: can return an error. In this test yes Dune | head -c 100000 | ./fh | head -1 made fh end with status 141 (128 + 13). A tool that needs to clean up or to report EPIPE ignores the signal at startup with signal(SIGPIPE, SIG_IGN), then handles the error return; see POSIX Processes, Signals & I/O.

  • printf and NSFileHandle use different buffers. printf to a pipe is block-buffered by the C library, while writeData: writes immediately, so a program that mixes both can emit lines out of order. Pick one for output, or call fflush(stdout) before a handle write.

  • NSLog is not an output function. It writes a time-stamped, process-identified line to standard error (for example 2026-10-25 14:42:13.208 nslog[88439:6337083] hello 1) and, on macOS, to the unified logging system. Do not use it for a tool’s normal output; use printf or NSFileHandle, and fprintf(stderr, ...) for messages meant for the user.

  • There is no portable way to ask NSFileHandle whether a stream is a terminal. Use isatty(STDOUT_FILENO) from <unistd.h> to decide on colour or progress output, as described in TTY, Colour & Interactivity.

Source: Apple — FileHandle. The streams, isatty and buffering from the C side are on C input, output and files.

Running subprocesses with NSTask

NSTask (called Process in Swift and in Apple’s current documentation) starts another program without a shell. The executable is given as a file URL, the arguments as an array of strings (no quoting or globbing happens), and the standard streams as NSPipe or NSFileHandle objects. The example runs /usr/bin/sort -f as a filter: it writes three titles to the child’s standard input, closes that end so the child sees end-of-file, reads the sorted result and returns the child’s exit status.

#import <Foundation/Foundation.h>

int main(void) {
    @autoreleasepool {
        NSTask *task = [[NSTask alloc] init];
        task.executableURL = [NSURL fileURLWithPath:@"/usr/bin/sort"];
        task.arguments = @[@"-f"];

        NSPipe *toChild = [NSPipe pipe];
        NSPipe *fromChild = [NSPipe pipe];
        task.standardInput = toChild;
        task.standardOutput = fromChild;
        task.standardError = NSFileHandle.fileHandleWithStandardError;

        NSError *error = nil;
        if (![task launchAndReturnError:&error]) {
            fprintf(stderr, "shelf: cannot launch sort: %s\n", error.localizedDescription.UTF8String);
            return 127;
        }
        [toChild.fileHandleForWriting writeData:[@"hyperion\nDune\nemma\n" dataUsingEncoding:NSUTF8StringEncoding] error:NULL];
        [toChild.fileHandleForWriting closeAndReturnError:NULL];   // EOF for the child

        // Read before waiting: a full pipe would otherwise block the child forever.
        NSData *sorted = [fromChild.fileHandleForReading readDataToEndOfFileAndReturnError:NULL];
        [task waitUntilExit];

        fwrite(sorted.bytes, 1, sorted.length, stdout);
        if (task.terminationReason == NSTaskTerminationReasonUncaughtSignal) {
            fprintf(stderr, "sort killed by signal %d\n", task.terminationStatus);
            return 128 + task.terminationStatus;
        }
        return task.terminationStatus;
    }
}
$ ./task
Dune
emma
hyperion
Property or method Behaviour

executableURL, arguments

There is no PATH lookup: a relative name such as sort failed to launch with a "file doesn’t exist" error in a test. Give the absolute path, search PATH yourself, or run /usr/bin/env with the program name as its first argument.

environment

If left nil, the child inherits the parent’s environment. If set, it replaces it completely, so copy NSProcessInfo.processInfo.environment into a mutable dictionary first and add to it.

currentDirectoryURL

The child’s working directory; the default is the parent’s.

standardInput, standardOutput, standardError

An NSPipe or NSFileHandle. If left unset, the child inherits the parent’s streams, which is what a tool that wants the child to talk to the terminal directly should do.

launchAndReturnError:

Starts the child and returns NO with an NSError if it cannot be started (for example, a missing file). The older launch raises an Objective-C exception and is deprecated.

waitUntilExit

Blocks, running the current run loop, until the child ends. It returns after terminationStatus is valid.

terminationStatus, terminationReason

The exit code, or the signal number when terminationReason is NSTaskTerminationReasonUncaughtSignal. Always check the reason, because status 9 means different things in the two cases.

terminationHandler

A block called on a background queue when the child ends; the way to avoid blocking. Hop back to the main queue with dispatch_async before touching state owned by the main thread.

interrupt, terminate, suspend, resume

Send SIGINT, SIGTERM, SIGSTOP and SIGCONT to the child. There is no method for SIGKILL; use kill(task.processIdentifier, SIGKILL).

Pitfalls:

  • Deadlock on full pipes. A pipe buffer is finite. If the parent only calls waitUntilExit while the child is blocked writing a large output, neither side makes progress. Read the output (or give the child a file) before waiting, as in the listing, or read incrementally through readabilityHandler.

  • Always close the write end of the child’s standard input when the data is complete, or a filter such as sort waits for more input forever.

  • Never build a command string for /bin/sh -c from user input. Keep program and arguments separate in arguments: nothing in that array is interpreted by a shell, which removes the usual injection risk.

  • Child processes inherit all inheritable descriptors. Tools that open sensitive files should set the close-on-exec flag (O_CLOEXEC) on them; see POSIX Processes, Signals & I/O.

  • NSTask is not available on iOS, tvOS or watchOS. It is a macOS (and GNUstep) API.

Asynchronous work: GCD signal sources and the run loop

A one-shot tool runs top to bottom and exits. A tool that has to wait (for a child process, a timer, a network event or a signal) needs an event loop. On macOS there are two cooperating ones: the main dispatch queue of Grand Central Dispatch (GCD) and the main run loop (NSRunLoop / CFRunLoop). The main dispatch queue is serviced by the main run loop, so either can be the loop that keeps main alive.

Signals are the classic trouble spot for a daemon because a signal handler may only call async-signal-safe functions (printf is not one of them). GCD solves this with signal sources: the handler is an ordinary block that GCD runs on a queue of your choice, outside signal context, so it can use Objective-C and the C library freely. The shelfd daemon below checks for overdue loans every second, reloads its configuration on SIGHUP and shuts down on SIGTERM.

#import <Foundation/Foundation.h>
#include <signal.h>

static dispatch_source_t makeSignalSource(int signo, dispatch_block_t handler) {
    signal(signo, SIG_IGN);   // the default action must be off, or the process dies first
    dispatch_source_t src =
        dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, (uintptr_t)signo, 0, dispatch_get_main_queue());
    dispatch_source_set_event_handler(src, handler);
    dispatch_resume(src);
    return src;
}

int main(void) {
    @autoreleasepool {
        __block int ticks = 0;

        dispatch_source_t timer =
            dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
        dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 100 * NSEC_PER_MSEC);
        dispatch_source_set_event_handler(timer, ^{
            printf("shelfd: overdue-loan check %d\n", ++ticks);
            fflush(stdout);
        });
        dispatch_resume(timer);

        dispatch_source_t term = makeSignalSource(SIGTERM, ^{
            printf("shelfd: SIGTERM, shutting down after %d checks\n", ticks);
            fflush(stdout);
            exit(0);
        });
        dispatch_source_t hup = makeSignalSource(SIGHUP, ^{
            printf("shelfd: SIGHUP, reloading configuration\n");
            fflush(stdout);
        });
        (void)term; (void)hup;   // keep the sources alive for the life of the process

        dispatch_main();         // never returns; or [[NSRunLoop mainRunLoop] run]
    }
}
$ ./shelfd &
shelfd: overdue-loan check 1
shelfd: overdue-loan check 2
shelfd: overdue-loan check 3
$ kill -HUP %1
shelfd: SIGHUP, reloading configuration
shelfd: overdue-loan check 4
$ kill -TERM %1
shelfd: SIGTERM, shutting down after 4 checks

The points that make this work:

  • signal(signo, SIG_IGN) is required. The dispatch source receives a signal that is ignored or blocked; if the default action (termination for SIGTERM) is still installed, the process dies before the source can run.

  • The sources are held in local variables. Under ARC, a dispatch source that is released stops delivering events, so the variables must stay alive for as long as the loop runs. Here main never returns, which does that implicitly.

  • dispatch_main() parks the main thread and services the main queue forever. An alternative is to run [[NSRunLoop mainRunLoop] run], which also lets Foundation timers, NSTask termination handlers and readabilityHandler callbacks run.

  • Run the handlers on the main queue to keep daemon state single-threaded; use a serial queue of your own if the main thread has other work.

  • Exit from the handler for SIGTERM after finishing cleanup. exit runs functions registered with atexit and flushes the C library’s streams.

When the loop is meant to end after some asynchronous work (rather than run forever), let the work flag completion on the main queue and run the loop in a condition loop, so main can return an exit status. The program waits for a one-second child without blocking on waitUntilExit.

#import <Foundation/Foundation.h>

int main(void) {
    @autoreleasepool {
        NSTask *task = [[NSTask alloc] init];
        task.executableURL = [NSURL fileURLWithPath:@"/bin/sleep"];
        task.arguments = @[@"1"];

        __block BOOL finished = NO;
        __block int status = -1;
        task.terminationHandler = ^(NSTask *t) {
            // Runs on a background queue; hop to the main queue to touch main-thread state.
            int s = t.terminationStatus;
            dispatch_async(dispatch_get_main_queue(), ^{ status = s; finished = YES; });
        };
        NSError *error = nil;
        if (![task launchAndReturnError:&error]) return 127;

        NSRunLoop *loop = NSRunLoop.currentRunLoop;
        while (!finished && [loop runMode:NSDefaultRunLoopMode beforeDate:NSDate.distantFuture]) {}
        printf("child exited with %d\n", status);
        return status;
    }
}
$ ./async
child exited with 0

A version that called CFRunLoopStop from the termination handler’s background queue instead of dispatching to the main queue did not return when it was tried on macOS 27, so the pattern above is the one to copy.

Pitfalls:

  • A run loop with no input sources returns immediately. Timers, ports, dispatch sources and main-queue work are what keep it running; a tool whose only job is to wait for a signal needs a signal source, as above.

  • Blocks capture variables by value unless they are marked __block. A counter incremented inside a handler needs the qualifier.

  • Do not mix a hand-written sigaction handler and a dispatch source for the same signal.

  • This pattern is for daemons that run under a supervisor. The platform specifics (launchd property lists, KeepAlive, logging with os_log) are on Long-Running Processes & Services.

Source: Apple — DispatchSource, Apple — Dispatch and Apple — RunLoop. The archived Threading Programming Guide — Run Loops explains how input sources and modes decide when a run loop returns. Apple’s pages for these APIs are written for Swift; the Objective-C names used here (dispatch_source_create, DISPATCH_SOURCE_TYPE_SIGNAL) are the C API they wrap. The concurrency model of the language is on Concurrency in Objective-C.

Universal binaries, signing and distribution

A binary built by default on an Apple silicon Mac contains only an arm64 slice. Passing both architectures to clang builds a universal (fat) binary that runs natively on Apple silicon and Intel Macs.

$ clang -fobjc-arc -framework Foundation -arch arm64 -arch x86_64 -o shelf shelf.m
$ lipo -info shelf
Architectures in the fat file: shelf are: x86_64 arm64
$ otool -L shelf
shelf:
	/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 5027.0.69)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)

The tool links dynamically against system libraries that ship with macOS, so the binary is small (about 51 KB for the arm64-only build of the shelf above) and nothing has to be installed next to it. Foundation is a system framework that Apple provides only as a dynamic library, so the tool depends on the macOS it runs on; this is one reason the portable choice for tools that must also run on Linux and Windows is usually not Objective-C.

Distributing the tool to other people is covered once for all languages: a bare binary can be signed and notarized but not stapled, so ship it in a signed installer package, disk image or archive; see Signing, Notarization & OS Trust and macOS Distribution Outside the App Store. Building fat binaries for several architectures and OSes with different toolchains is on Native Binaries & Cross-Compilation.

GNUstep on Linux and Windows

GNUstep is a free implementation of the OpenStep specification and much of Cocoa. Its gnustep-base library provides NSProcessInfo, NSUserDefaults, NSFileHandle, NSTask, NSRunLoop and the other Foundation classes used on this page, and gnustep-make provides a makefile framework. The library is built against the GNUstep Objective-C runtime rather than Apple’s, and clang compiles it. A command-line tool uses tool.make:

include $(GNUSTEP_MAKEFILES)/common.make

TOOL_NAME = shelf
shelf_OBJC_FILES = shelf.m

include $(GNUSTEP_MAKEFILES)/tool.make
$ . /usr/share/GNUstep/Makefiles/GNUstep.sh
$ make

The location of GNUstep.sh depends on the distribution and on how GNUstep was installed. Build without the makefile framework by asking gnustep-config for the flags; the exact flag set depends on the installed version and the Objective-C runtime it uses.

$ clang $(gnustep-config --objc-flags) shelf.m $(gnustep-config --base-libs) -o shelf

These GNUstep commands were not run while the page was written. What to expect from the porting layer:

  • The getopt_long, sysexits.h, signal and isatty parts of this page are plain C and run unchanged on Linux with glibc. <sysexits.h> is not part of the Windows C runtime; define the exit codes yourself there.

  • NSTask, NSRunLoop, NSFileHandle and NSUserDefaults exist in GNUstep, but GNUstep’s own documentation is the authority on which methods and which behaviour (for example the argument domain, or the error-returning launchAndReturnError: variants Apple added in recent macOS versions) are available in your version. Apple’s current reference should not be assumed to apply.

  • Grand Central Dispatch is a separate library (libdispatch) on other platforms, and whether dispatch_main and signal sources are available depends on how GNUstep and libdispatch were built. Windows has no POSIX signals, so a Windows tool needs the console control handler described on Windows Console & Processes.

  • Standard terminal behaviour, ANSI escape sequences and the UTF-8 code page questions on Windows are independent of the language; the same page applies.

If the goal is one program for macOS, Linux and Windows, the languages with first-class cross-platform toolchains (Swift, Rust, Go, .NET, C++) are easier choices than GNUstep; the comparison is on The CLI Landscape & Choosing a Stack and Cross-Platform Portability.

Pitfalls and security notes

  • Exit statuses are 0 to 255. Use sysexits.h names or your own table; do not return error codes outside that range and do not return NSInteger values from main without checking them.

  • Output belongs on stdout, diagnostics on stderr. Use printf / NSFileHandle for the former and fprintf(stderr, ...) for the latter. NSLog is only for debugging.

  • Treat argument values as untrusted. Validate numbers (strtol with an end-pointer check, as above, rather than NSString.intValue, which returns 0 for garbage), validate paths before opening files, and never pass user text to a shell. NSString formatting with a user-supplied format string ([NSString stringWithFormat:userText]) is a format-string vulnerability; always pass a constant format string and give the text as an argument.

  • Do not log secrets that arrive through arguments. Every user can read the argument vector of every process on macOS and Linux (through ps), so a password or token should be read from a file descriptor, a prompt without echo, or the environment, not from -password secret.

  • Autorelease pools in loops. A loop that processes millions of records should drain a pool per iteration. Wrap the body in an @autoreleasepool { ... } block inside the loop and memory stays flat.

  • Exceptions are not errors. Foundation raises NSException for programming mistakes (such as an out-of-bounds array access) and the tool terminates with an uncaught-exception message. The Cocoa convention for expected failures is an NSError ** out-parameter, as used throughout this page. See Errors and Exceptions in Objective-C.