Skip to content
C.W.K.
Stream
Lesson 02 of 06 · published

When Swift Needs C

~15 min · packages-projects, c-interop, system-libraries, swiftpm

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Try the import first. A C target is for what Swift truly cannot see."

Why C Still Shows Up

Much of macOS below the frameworks is a C API: process information in libproc, archives in libarchive, sqlite3, POSIX sockets, forkpty. Swift imports many C headers directly through the SDK's module maps, so import Darwin gets you a lot. But some headers are not exposed as a clean Swift module, function-like macros do not import at all (WIFEXITED is simply not in scope from Swift), fixed-size C arrays arrive as awkward tuples, and some libraries you want are third-party. For those, SwiftPM lets a package contain a C target next to its Swift targets.

The Shape of a C Target

  • A target folder like Sources/CSparkProc/ with .c files, and a public header under include/ (the default publicHeadersPath).
  • SwiftPM generates a module map for it, so a Swift target that depends on CSparkProc simply writes import CSparkProc.
  • Keep the C surface tiny and boring: plain functions, plain types, a return code for failure. Put all interpretation on the Swift side.

The family's terminal app needed the working directory of the shell running in each pane, to open new panes in the same place. The answer lives in proc_pidinfo with PROC_PIDVNODEPATHINFO, and the app wrapped it in a one-file C target with a single public function, on the belief that libproc could not be imported from Swift. Measured on Swift 6.3.3 with the macOS 26.5 SDK, that belief is wrong: import Darwin exposes both names, and the call compiles and returns the working directory. The shim still earns its keep as one small, testable C surface that also carries a second call for the foreground process group of a pseudo-terminal, but the order is the lesson: try the import, and write C for what fails. A file workbench took the same route to wrap libarchive, linking the system library with linkerSettings: [.linkedLibrary("archive")].

Linking, and the Line You Should Not Cross Here

System libraries that ship with macOS link with .linkedLibrary (sqlite3, archive) or .linkedFramework (AppKit, PDFKit). A third-party library you install with Homebrew is a different matter: a green swift build against it proves only that your Mac has it. Shipping one means bundling every dylib in its dependency closure inside the app, rewriting their load paths and signing each one — the family's native video player does exactly that, and its quest (/cwk-quests/ashen-reel-quest) tells that story in depth. This lesson stays with the C that the OS already provides.

The Other Direction

Swift 6.3 added the @c attribute, which exposes a Swift function or enum to C and generates the matching header declaration. You will rarely need it in an app, but it is now the supported way to hand a Swift implementation to C code in the same project.

Code

A one-file C target beside the Swift targets·text
SparkKit/
  Package.swift                  .target(name: "CSparkProc")
  Sources/
    CSparkProc/
      include/CSparkProc.h       the public header Swift will see
      CSparkProc.c               the only file that includes <libproc.h>
    SparkMac/
      ProcessDirectory.swift     import CSparkProc
The C side stays tiny; the Swift side interprets·swift
// Sources/CSparkProc/include/CSparkProc.h
//   #include <sys/types.h>
//   #include <stddef.h>
//   int spark_process_cwd(pid_t pid, char *buffer, size_t length);
//
// Sources/CSparkProc/CSparkProc.c
//   #include "CSparkProc.h"
//   #include <libproc.h>
//   #include <string.h>
//   int spark_process_cwd(pid_t pid, char *buffer, size_t length) {
//       struct proc_vnodepathinfo info;
//       int size = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, sizeof(info));
//       if (size != sizeof(info) || length == 0) { return -1; }
//       strncpy(buffer, info.pvi_cdir.vip_path, length - 1);
//       buffer[length - 1] = '\0';
//       return 0;
//   }

// Sources/SparkMac/ProcessDirectory.swift
#if os(macOS)
import Foundation
import CSparkProc

public enum ProcessDirectory {
    /// The working directory of another process, through a one-file C shim over libproc.
    public static func current(of pid: pid_t) -> String? {
        var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
        guard spark_process_cwd(pid, &buffer, buffer.count) == 0 else { return nil }
        return buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
    }
}
#endif

External links

Exercise

Add the CSparkProc target and ProcessDirectory to SparkKit, plus a tiny cwd-probe executable that prints ProcessDirectory.current(of: getpid()) next to FileManager.default.currentDirectoryPath. Run it from two different folders and confirm the values match. Then pass the pid of a shell in another Terminal window and watch it follow that shell's cd. Finally, call proc_pidinfo straight from Swift with import Darwin and confirm it prints the same directory, then try WIFEXITED and read the error.
Hint
pgrep -n zsh gives you the newest zsh pid. If the C call returns -1 for another user's process, that is the kernel refusing, not a bug in the shim — say so in the Swift return value rather than guessing.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.