Skip to the content.

SwiftPM의 제약에 대해

애플은 WWDC 2021에서 Swift Playgrounds 4.0 부터 iPadOS에서 앱 개발을 할 수 있다고 발표했습니다. (기사) 이 글에서는 Swift Playgrounds에서 앱을 개발하는 기법을 SwiftPM이라고 하겠습니다. (사실 정식적인 명칭이 아직 없어요.)

SwiftPM은 Swift Playgrounds에서 앱을 개발할 수 있다는 장점이 있고, SPM 기반이며 Tuist 처럼 앱 프로젝트를 관리할 수 있는 First-Party 프레임워크로도 볼 수 있습니다. 아마 몇년 뒤에 Tuist를 대체하지 않을까 싶어요.

저는 최근에 Gitmoji를 관리할 수 있는 앱인 AnGitmoji를 SwiftPM으로 개발했으며, 개발하면서 알게 된 SwiftPM의 기본 구조와 제약 사항을 이 글에서 적고자 합니다.

SwiftPM의 기본 구조

SwiftPM을 처음 제작하면 아래처럼 Package.swift 파일이 생성됩니다.

// swift-tools-version: 5.7

// WARNING:
// This file is automatically generated.
// Do not edit it by hand because the contents will be replaced.

import PackageDescription
import AppleProductTypes

let package = Package(
    name: "My App",
    platforms: [
        .iOS("16.0")
    ],
    products: [
        .iOSApplication(
            name: "My App",
            targets: ["AppModule"],
            displayVersion: "1.0",
            bundleVersion: "1",
            appIcon: .placeholder(icon: .palette),
            accentColor: .presetColor(.red),
            supportedDeviceFamilies: [
                .pad,
                .phone
            ],
            supportedInterfaceOrientations: [
                .portrait,
                .landscapeRight,
                .landscapeLeft,
                .portraitUpsideDown(.when(deviceFamilies: [.pad]))
            ],
            capabilities: [
                .camera(purposeString: "Test")
            ]
        )
    ],
    targets: [
        .executableTarget(
            name: "AppModule",
            path: "."
        )
    ]
)

일단 최상단에 아래와 같은 주석이 적힌 것을 볼 수 있습니다.

// WARNING:
// This file is automatically generated.
// Do not edit it by hand because the contents will be replaced.

SPM의 Package 및 Plugin의 Package.swift은 수정이 자유로운 것과 달리, SwiftPM은 Package.swift를 수정하지 말라고 경고하고 있습니다. 근데 사실 건드려도 상관이 없습니다. 서드파티 SPM 패키지를 추가하셔도 되고 타겟을 여러 개 만드셔도 됩니다. 제가 앞서 말씀드렸던 AnGitmoji 앱의 Package.swift 파일을 보시면 커스텀을 하고 있습니다.

그 밑에는 import AppleProductTypes라는 프레임워크가 보입니다. 애플은 AppleProductTypes의 정보를 공개하지 않았지만, /Applications/Xcode.app/Contents/PlugIns/IDESwiftPackageCore.framework/Versions/A/Frameworks/SwiftPM.framework/Versions/A/SharedSupport/ManifestAPI/AppleProductTypes.swiftmodule/arm64-apple-macos.swiftinterface을 보면 무엇인지 알 수 있습니다.

// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 5.7.1 (swiftlang-5.7.1.135.3 clang-1400.0.29.51)
// swift-module-flags: -target arm64-apple-macos11.0 -enable-objc-interop -enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -O -package-description-version 999.0 -module-link-name AppleProductTypes -module-name AppleProductTypes
// swift-module-flags-ignorable: -enable-bare-slash-regex -user-module-version 21508
import PackageDescription
import Swift
extension PackageDescription.Product {
    public static func iOSApplication(
        name: Swift.String, targets: [Swift.String],
        bundleIdentifier: Swift.String? = nil,
        teamIdentifier: Swift.String? = nil,
        displayVersion: Swift.String? = nil,
        bundleVersion: Swift.String? = nil,
        iconAssetName: Swift.String? = nil,
        accentColorAssetName: Swift.String? = nil,
        supportedDeviceFamilies: [PackageDescription.ProductSetting.IOSAppInfo.DeviceFamily],
        supportedInterfaceOrientations: [PackageDescription.ProductSetting.IOSAppInfo.InterfaceOrientation],
        capabilities: [PackageDescription.ProductSetting.IOSAppInfo.Capability] = [],
        additionalInfoPlistContentFilePath: Swift.String? = nil
    ) -> PackageDescription.Product
    
  @available(_PackageDescription 5.6)
    public static func iOSApplication(
        name: Swift.String,
        targets: [Swift.String],
        bundleIdentifier: Swift.String? = nil,
        teamIdentifier: Swift.String? = nil,
        displayVersion: Swift.String? = nil,
        bundleVersion: Swift.String? = nil,
        appIcon: PackageDescription.ProductSetting.IOSAppInfo.AppIcon? = nil,
        accentColor: PackageDescription.ProductSetting.IOSAppInfo.AccentColor? = nil,
        supportedDeviceFamilies: [PackageDescription.ProductSetting.IOSAppInfo.DeviceFamily],
        supportedInterfaceOrientations: [PackageDescription.ProductSetting.IOSAppInfo.InterfaceOrientation],
        capabilities: [PackageDescription.ProductSetting.IOSAppInfo.Capability] = [],
        appCategory: PackageDescription.ProductSetting.IOSAppInfo.AppCategory? = nil,
        additionalInfoPlistContentFilePath: Swift.String? = nil
    ) -> PackageDescription.Product
}

AppleProductTypes의 API는 위 코드처럼 되어 있습니다. 앱의 이름, Bundle ID, 버전, 빌드, 앱 아이콘, 지원 기기, 앱 회전 방향, Capabilities를 정할 수 있는 것을 알 수 있습니다.

추가적으로, Info.plist를 커스텀 할 수 있습니다. 이걸 이용해서 Document-based App을 만드는 꼼수가 있습니다.

SwiftPM의 제약

위에 AppleProductTypes의 API를 보셨다시피… Xcode Project 기반 앱에 비해 설정할 수 있는 값이 굉장히 제한적입니다. 특히 SwiftPM 앱을 Swift Playgrounds에서 실행하면 제약이 많으며, SwiftPM 앱을 Xcode에서 실행하면 제약이 그나마 적은 편입니다.

겪은 제약 사항을 적자면,

Entitlements 커스텀이 불가능 합니다.

Xcode Project 기반 앱과 달리 SwiftPM 앱은 Entilements 커스텀이 불가능합니다. iCloud를 활성화시키기 위한 com.apple.developer.icloud-services 같은 entitlement를 쓸 수 없다는 소리입니다. 아래처럼 애플이 정해놓은 30개의 Capabilites만 이용이 가능합니다.

public enum Capability : Swift.Equatable, Swift.Encodable {
      case appTransportSecurity(configuration: PackageDescription.ProductSetting.IOSAppInfo.AppTransportSecurityConfiguration, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case bluetoothAlways(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case calendars(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case camera(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case contacts(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case faceID(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case fileAccess(_: PackageDescription.ProductSetting.IOSAppInfo.FileAccessLocation, mode: PackageDescription.ProductSetting.IOSAppInfo.FileAccessMode, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case incomingNetworkConnections(_: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case localNetwork(purposeString: Swift.String, bonjourServiceTypes: [Swift.String]? = nil, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case locationAlwaysAndWhenInUse(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case locationWhenInUse(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case mediaLibrary(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case microphone(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case motion(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case nearbyInteractionAllowOnce(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case outgoingNetworkConnections(_: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case photoLibrary(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case photoLibraryAdd(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case reminders(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case speechRecognition(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      case userTracking(purposeString: Swift.String, _: PackageDescription.ProductSetting.IOSAppInfo.DeviceFamilyCondition? = nil)
      public static func == (a: PackageDescription.ProductSetting.IOSAppInfo.Capability, b: PackageDescription.ProductSetting.IOSAppInfo.Capability) -> Swift.Bool
}

Swift Package Manager를 직접 커스텀하면 될 수도 있습니다. PIF의 Build Settings를 커스텀해주면 될 것 같긴 한데요. 링크 제가 이러한 접근 방법을 시도했으나 애플이 AppleProductTypes의 소스코드는 공개하지 않아 어려움이 있어 몇시간 동안 삽질하다가 저는 포기했네요.

Plugin을 통해 앱 빌드 후에 Entitlements를 주입하면 되지 않을까?’라는 생각도 해봤습니다. Plugin은 Xcode의 Run Script build phases와 유사합니다. 하지만 Plugin와 Run Script build phases는 실행되는 타이밍이 다릅니다.

즉, 제가 Plugin을 통해 커스텀 Entitlements를 주입하는 스크립트를 만들어줘도, 어차피 Signing 단계에서 Entitlements가 Capabilities로 대체가 되어서 의미가 없더라고요.

결국 저는 이 문제를 해결하지 못했어요. 아마 애플이 API를 만들어주지 않을까요?

Swift Playgrounds는 Objective-C를 지원하지 않습니다.

Objective-C 코드를 추가할 경우 Swift Playgrounds에서 빌드되지 않으며, Xcode에서는 빌드가 되는데 아래와 같은 경고가 뜹니다. Swift Playgrounds는 clang이 없어서 그런 것 같네요. 아마 C/C++/Metal도 안 될 것 같네요? Swift 언어만 지원하는 것 같습니다.

This Swift Playgrounds project depends on a target containing non-Swift source code, and will therefore not be buildable in Swift Playgrounds.

Swift Playgrounds는 Core Data의 mom 빌드를 지원하지 않습니다.

Swift Playgrounds는 Core Data의 모델을 빌드해주는 momc을 갖고 있지 않습니다. Xcode에서는 momc이 있으므로 가능합니다.

만약에 강제로 Swift Playgrounds에서 Core Data를 쓰고 싶다! 라고 하면 macOS에서 momc을 직접 돌려서 xcdatamodeld 파일을 mom으로 빌드시키고, 빌드된 mom 파일을 Resources에 넣어주면 작동합니다. 아래는 명령어 예시입니다.

$(xcode-select -p)/usr/bin/momc --sdkroot $(xcode-select -p)/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk --iphoneos-deployment-target 14.0 --module NamuTrackerApp ${model} ".theos/_/Library/Application Support/NamuTracker"

제가 예전에 NamuTracker를 만들었는데, theos 환경에서 앱을 개발할 때 (Xcode 없이 앱을 개발할 때) 쓴 기법이기도 합니다.

아니면 mom 없이 모델을 만드시는 방법도 있습니다. https://tigi44.github.io/ios/iOS,-Swift-Core-Data-Model-in-a-Swift-Package/

Swift Playgrounds는 Unit Testing을 지원하지 않습니다.

하지만 Xcode에서 SwiftPM을 실행하면 지원합니다.

iOS App만 개발할 수 있습니다.

위에서 보여 드렸다시피 AppleProductTypes은 iOS App 개발만 지원합니다.

extension PackageDescription.Product {
    public static func iOSApplication /* ... */
}

하지만 Mac Catalyst는 지원해서 macOS 앱을 제작할 수는 있으나, Native macOS 앱 개발에 비하면 제약이 많아 Private API로 해결해야 합니다.

AnGitmoji의 경우 objc_msgSend로 Cocoa API를 호출해서 Mac Catalyst에서 구현이 불가능한 기능을 구현했습니다. 링크

Reference