diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cf6a38..eee8473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,12 @@ jobs: - name: Release build run: make release + + # The sandboxed configuration that goes to the Mac App Store. Ad-hoc + # signed here, which is enough to prove it compiles and carries the + # sandbox entitlement. + - name: App Store build + run: | + make appstore-build + codesign --display --entitlements - --xml build/appstore/Build/Products/AppStore/Cleared.app \ + | plutil -convert json -o - - | grep -q '"com.apple.security.app-sandbox":true' diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d42ac..5f0cd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,12 @@ The release that makes Cleared something you can leave running. - Timestamps in the panel tick while it is open. - Logging to the unified log under `net.hlnd.Cleared`. - CI on pull requests, and a script that produces a notarized build. +- A sandboxed `AppStore` build configuration for the Mac App Store, which is + what **Product → Archive** now builds, and `make appstore` to archive and + export the signed package. In that build a pasted token is the only way to + connect, because the sandbox rules out reading a `gh` login. +- *Create a token on GitHub…* on the connect screen, which opens the new-token + page with the right scope selected. ### Changed diff --git a/Local.xcconfig.example b/Local.xcconfig.example index 091306a..14d2b07 100644 --- a/Local.xcconfig.example +++ b/Local.xcconfig.example @@ -12,3 +12,13 @@ // CLEARED_SIGN_IDENTITY = Apple Development: Your Name (XXXXXXXXXX) // CLEARED_SIGN_STYLE = Manual // DEVELOPMENT_TEAM = YOURTEAMID + +// ── Mac App Store ──────────────────────────────────────────────────── +// +// The AppStore configuration (what Product → Archive builds) is sandboxed and +// can be signed under a different team from your day-to-day builds — the one +// that holds your Apple Distribution certificate. Without these it falls back +// to ad-hoc signing, which still builds and runs but cannot be uploaded. +// +// CLEARED_APPSTORE_TEAM = YOURTEAMID +// CLEARED_APPSTORE_IDENTITY = Apple Development diff --git a/Makefile b/Makefile index d8b6e04..d082d24 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate build test run install preview icon release dist scan clean +.PHONY: generate build test run install preview icon release dist appstore appstore-build scan clean RELEASE_APP := build/Build/Products/Release/Cleared.app INSTALL_DIR ?= /Applications @@ -46,6 +46,17 @@ icon: dist: @./Scripts/release.sh +## Archive for the Mac App Store and export the signed package to +## dist/appstore/. Uploads nothing. See Scripts/appstore.sh. +appstore: + @./Scripts/appstore.sh + +## Build the sandboxed App Store configuration without archiving. Works with +## no signing set up, so CI uses it to keep that configuration compiling. +appstore-build: generate + xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration AppStore \ + -derivedDataPath build/appstore build + ## Fail loudly if anything token-shaped is about to be committed. scan: @./Scripts/scan-secrets.sh diff --git a/README.md b/README.md index c0da968..3401b77 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,11 @@ Cleared needs a GitHub token with the **`repo`** scope. - **If you use the `gh` CLI:** click *Use my gh CLI login*. Cleared shells out to `gh auth token` once and stores the result. -- **Otherwise:** paste a [personal access token](https://github.com/settings/tokens). +- **Otherwise:** paste a personal access token. *Create a token on GitHub…* + opens the new-token page with the scope already selected. + +The Mac App Store version only offers the second. It runs in the App Sandbox, +which lets it neither run `gh` nor read the login `gh` keeps. The token is written to your login Keychain under `net.hlnd.Cleared` and read back per request. It is never written to `UserDefaults`, never written to disk @@ -154,6 +158,7 @@ that see one PR a month get picked up without being added by hand. make build # debug build make test # unit tests make install # Release build into /Applications +make appstore-build # the sandboxed App Store configuration make preview # render the panel against sample data make preview FIXTURE=overflow # ... with enough rows to scroll make preview FIXTURE=empty # ... the all-clear state @@ -219,6 +224,42 @@ make dist # writes dist/Cleared-.zip runtime, submits to Apple, staples the ticket and asks Gatekeeper to confirm before it reports success. +### Mac App Store + +The store requires the App Sandbox, so there is a third build configuration, +`AppStore`: Release plus `Resources/Cleared.entitlements` (the sandbox, and +outbound network access — nothing else). It is what the scheme archives with, +so in Xcode it is just **Product → Archive**, then **Distribute App → App Store +Connect**. + +Set the team that will submit in `Local.xcconfig` first: + +``` +CLEARED_APPSTORE_TEAM = YOURTEAMID +CLEARED_APPSTORE_IDENTITY = Apple Development +``` + +It can be a different team from the one that signs your day-to-day builds. That +team's Apple ID has to be signed in under Xcode → Settings → Accounts; Xcode +then registers the bundle ID and creates the provisioning profile itself. + +From the command line, `make appstore` does the same archive, exports the +signed package to `dist/appstore/Cleared.pkg`, and checks that the app inside +is sandboxed and signed for distribution. It uploads nothing. The archive is +written where Xcode's Organizer looks, so you can upload that same build from +**Window → Organizer**. + +What differs in the sandboxed build: + +- No *Use my gh CLI login* — a pasted token is the only way to connect. +- Its token is kept under a separate Keychain entry from a direct build's. The + two are signed differently and could not read each other's entry anyway; + separate names mean neither ever triggers a Keychain prompt for the other's. +- Its settings live in the app's sandbox container, so they start fresh. + +`make appstore-build` builds that configuration without archiving and needs no +signing set up. CI runs it on every pull request. + ### Layout ``` diff --git a/Resources/Cleared.entitlements b/Resources/Cleared.entitlements new file mode 100644 index 0000000..d053f5d --- /dev/null +++ b/Resources/Cleared.entitlements @@ -0,0 +1,13 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Resources/Info.plist b/Resources/Info.plist index 511d667..1381704 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -18,6 +18,10 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + + ITSAppUsesNonExemptEncryption + LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion @@ -25,6 +29,6 @@ LSUIElement NSHumanReadableCopyright - MIT licensed. + Copyright © 2026 Kevin Holland. MIT licensed. diff --git a/Resources/PrivacyInfo.xcprivacy b/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..38bd6ec --- /dev/null +++ b/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,26 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/Scripts/appstore.sh b/Scripts/appstore.sh new file mode 100755 index 0000000..9133e20 --- /dev/null +++ b/Scripts/appstore.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Archives Cleared for the Mac App Store and exports the signed package that +# App Store Connect accepts. +# +# make appstore +# +# Needs CLEARED_APPSTORE_TEAM in Local.xcconfig (see Local.xcconfig.example) +# and that team's Apple ID signed in to Xcode → Settings → Accounts. Xcode +# registers the bundle ID and creates the App Store provisioning profile the +# first time this runs. +# +# The archive is written where Xcode's Organizer looks, so the same build can +# be uploaded from Window → Organizer → Distribute App. Nothing is uploaded +# here. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +xcodegen generate >/dev/null + +setting() { + xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration AppStore -showBuildSettings 2>/dev/null \ + | sed -n "s/^ *$1 = //p" | head -1 +} + +team=$(setting DEVELOPMENT_TEAM) +version=$(setting MARKETING_VERSION) +build=$(setting CURRENT_PROJECT_VERSION) +if [ -z "$team" ]; then + echo "✖ No App Store team. Set CLEARED_APPSTORE_TEAM in Local.xcconfig — see Local.xcconfig.example." >&2 + exit 1 +fi + +archive="$HOME/Library/Developer/Xcode/Archives/$(date +%Y-%m-%d)/Cleared $version ($build) $(date +%H.%M).xcarchive" +out=dist/appstore +options=build/ExportOptions.plist + +echo "▸ Archiving Cleared $version ($build) with the AppStore configuration" +mkdir -p "$(dirname "$archive")" build +xcodebuild archive -project Cleared.xcodeproj -scheme Cleared \ + -archivePath "$archive" -allowProvisioningUpdates >build/appstore-archive.log 2>&1 \ + || { tail -25 build/appstore-archive.log >&2; exit 1; } + +cat >"$options" < + + + + method + app-store-connect + destination + export + signingStyle + automatic + teamID + $team + + +PLIST + +echo "▸ Exporting, signed for the App Store" +rm -rf "$out" +xcodebuild -exportArchive -archivePath "$archive" -exportOptionsPlist "$options" \ + -exportPath "$out" -allowProvisioningUpdates >build/appstore-export.log 2>&1 \ + || { tail -25 build/appstore-export.log >&2; exit 1; } + +pkg=$(ls "$out"/*.pkg | head -1) +echo "▸ Checking what was produced" +pkgutil --check-signature "$pkg" | sed -n '1,6p' + +# Unpack the app from the package and confirm it is what the store requires: +# sandboxed, signed for distribution, and without the debugger entitlement +# that development builds carry. +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +pkgutil --expand-full "$pkg" "$work/pkg" >/dev/null +app=$(find "$work/pkg" -maxdepth 4 -name 'Cleared.app' | head -1) +entitlements=$(codesign --display --entitlements - --xml "$app" 2>/dev/null | plutil -convert json -o - - 2>/dev/null || echo '{}') +case "$entitlements" in + *'"com.apple.security.app-sandbox":true'*) ;; + *) echo "✖ The exported app is not sandboxed." >&2; exit 1 ;; +esac +case "$entitlements" in + *get-task-allow*) echo "✖ The exported app still has get-task-allow." >&2; exit 1 ;; +esac +codesign --verify --strict --deep "$app" + +echo "✓ $pkg" +echo " Archive: $archive" +echo " Upload it from Xcode's Organizer, or with Transporter." diff --git a/Sources/Cleared/Auth/Keychain.swift b/Sources/Cleared/Auth/Keychain.swift index f06d618..f87a746 100644 --- a/Sources/Cleared/Auth/Keychain.swift +++ b/Sources/Cleared/Auth/Keychain.swift @@ -16,7 +16,12 @@ enum Keychain { } } - private static let service = "net.hlnd.Cleared" + /// The App Store build keeps its token under a name of its own. It is + /// signed differently from a direct build, so it could not read that + /// build's item anyway — and merely trying would raise a Keychain password + /// prompt at launch. Separate names mean neither build ever asks for the + /// other's entry. + private static let service = AppEnvironment.isSandboxed ? "net.hlnd.Cleared.sandboxed" : "net.hlnd.Cleared" static func set(_ value: String, account: String) throws { let data = Data(value.utf8) diff --git a/Sources/Cleared/Auth/TokenStore.swift b/Sources/Cleared/Auth/TokenStore.swift index f51b7e0..90587d5 100644 --- a/Sources/Cleared/Auth/TokenStore.swift +++ b/Sources/Cleared/Auth/TokenStore.swift @@ -81,8 +81,12 @@ enum GHCLIImporter { "/usr/bin/gh", ] - static func locate() -> URL? { - candidatePaths.lazy + /// Nil in the App Store build, wherever `gh` is installed. The sandbox + /// lets Cleared neither run it nor read the login it keeps, so offering + /// the button there would only produce a failure. + static func locate(sandboxed: Bool = AppEnvironment.isSandboxed) -> URL? { + guard !sandboxed else { return nil } + return candidatePaths.lazy .filter { FileManager.default.isExecutableFile(atPath: $0) } .first .map { URL(fileURLWithPath: $0) } diff --git a/Sources/Cleared/ClearedApp.swift b/Sources/Cleared/ClearedApp.swift index 750378d..eb72910 100644 --- a/Sources/Cleared/ClearedApp.swift +++ b/Sources/Cleared/ClearedApp.swift @@ -46,11 +46,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { enum AppEnvironment { static let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + /// True in the App Store build. The system sets this variable for every + /// sandboxed process, so one binary can tell which way it was built. + static let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil + /// "0.4.0" — read from the bundle so the User-Agent and the Settings /// footer can never drift from `MARKETING_VERSION`. static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" } +private struct SandboxedBuildKey: EnvironmentKey { + static let defaultValue = AppEnvironment.isSandboxed +} + +extension EnvironmentValues { + /// Whether to show the App Store build's interface. An environment value + /// so the preview harness can show that interface from an ordinary build. + var isSandboxedBuild: Bool { + get { self[SandboxedBuildKey.self] } + set { self[SandboxedBuildKey.self] = newValue } + } +} + #if DEBUG extension AppDelegate { /// `open Cleared.app --args --ui-preview` renders the panel in an ordinary @@ -61,7 +78,7 @@ extension AppDelegate { /// catch — so the panel would otherwise be near-impossible to iterate on. private func openPreview() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: Theme.panelWidth + 48, height: 820), + contentRect: NSRect(x: 0, y: 0, width: Theme.panelWidth + 120, height: 820), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false @@ -80,6 +97,7 @@ extension AppDelegate { struct PreviewHarness: View { enum Fixture: String, CaseIterable { case full = "Full queue", overflow = "Overflowing", empty = "All clear" + case connect = "Connect", connectAppStore = "App Store" /// Matched on the case name, not the display label — "empty" has to /// select `.empty`, whose label is "All clear". @@ -88,6 +106,8 @@ struct PreviewHarness: View { case .full: "full" case .overflow: "overflow" case .empty: "empty" + case .connect: "connect" + case .connectAppStore: "connect-appstore" } } @@ -106,7 +126,7 @@ struct PreviewHarness: View { init() { let start = Fixture.fromCommandLine() let model = AppModel() - model.loadSample(empty: start == .empty, overflow: start == .overflow) + Self.show(start, in: model) _model = SwiftUI.State(initialValue: model) _fixture = SwiftUI.State(initialValue: start) } @@ -119,10 +139,11 @@ struct PreviewHarness: View { .pickerStyle(.segmented) .labelsHidden() .onChange(of: fixture) { _, new in - model.loadSample(empty: new == .empty, overflow: new == .overflow) + Self.show(new, in: model) } MenuPanel(model: model) + .environment(\.isSandboxedBuild, fixture == .connectAppStore) .background(.background) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(.separator)) @@ -133,5 +154,14 @@ struct PreviewHarness: View { .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + + private static func show(_ fixture: Fixture, in model: AppModel) { + switch fixture { + case .full: model.loadSample() + case .overflow: model.loadSample(overflow: true) + case .empty: model.loadSample(empty: true) + case .connect, .connectAppStore: model.loadConnectScreen() + } + } } #endif diff --git a/Sources/Cleared/Queue/AppModel.swift b/Sources/Cleared/Queue/AppModel.swift index 4fb39d6..a12fda1 100644 --- a/Sources/Cleared/Queue/AppModel.swift +++ b/Sources/Cleared/Queue/AppModel.swift @@ -97,7 +97,7 @@ final class AppModel { func start() async { guard !hasStarted, !isPreview else { return } hasStarted = true - Log.app.notice("starting") + Log.app.notice("starting\(AppEnvironment.isSandboxed ? " (sandboxed)" : "", privacy: .public)") guard vault.current() != nil else { phase = .needsToken return @@ -472,5 +472,14 @@ final class AppModel { lastRefresh = Date() phase = .ready } + + /// Parks the model on the connect screen, for laying that screen out. + func loadConnectScreen() { + isPreview = true + pollTask?.cancel() + pollTask = nil + login = nil + phase = .needsToken + } #endif } diff --git a/Sources/Cleared/UI/ConnectView.swift b/Sources/Cleared/UI/ConnectView.swift index 6d9074f..a16d5f8 100644 --- a/Sources/Cleared/UI/ConnectView.swift +++ b/Sources/Cleared/UI/ConnectView.swift @@ -2,11 +2,20 @@ import SwiftUI /// First run. The gh CLI path is one click because most people who need Cleared /// already have `gh auth login` behind them. +/// +/// The App Store build has no such path: the sandbox lets it neither run `gh` +/// nor read the login `gh` keeps. There a pasted token is the only way in, so +/// it is presented as the way in rather than as a fallback. struct ConnectView: View { let model: AppModel + @Environment(\.isSandboxedBuild) private var isSandboxed + @Environment(\.openURL) private var open @State private var pastedToken = "" @State private var isWorking = false + /// GitHub's new-token page with the scope and a name already filled in. + private static let newTokenURL = URL(string: "https://github.com/settings/tokens/new?scopes=repo&description=Cleared")! + var body: some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 3) { @@ -33,7 +42,7 @@ struct ConnectView: View { VStack(alignment: .leading, spacing: 3) { Label("A saved token exists but this build can't read it", systemImage: "lock.trianglebadge.exclamationmark") .font(.system(size: 11, weight: .medium)) - Text("\(Keychain.describe(status)). Rebuilding changes the app's ad-hoc signature, which invalidates the Keychain entry's access list. Connecting again replaces it.") + Text("\(Keychain.describe(status)). It was saved by a build with a different signature, which the Keychain entry's access list no longer matches. Connecting again replaces it.") .font(.system(size: 10)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -43,49 +52,59 @@ struct ConnectView: View { .clipShape(RoundedRectangle(cornerRadius: 5)) } - Button { - isWorking = true - Task { - await model.signInFromGHCLI() - isWorking = false - } - } label: { - HStack(spacing: 5) { - if isWorking { - ProgressView().controlSize(.small).scaleEffect(0.6) - } else { - Image(systemName: "terminal") + if !isSandboxed { + Button { + isWorking = true + Task { + await model.signInFromGHCLI() + isWorking = false } - Text("Use my gh CLI login") + } label: { + HStack(spacing: 5) { + if isWorking { + ProgressView().controlSize(.small).scaleEffect(0.6) + } else { + Image(systemName: "terminal") + } + Text("Use my gh CLI login") + } + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(isWorking || GHCLIImporter.locate() == nil) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isWorking || GHCLIImporter.locate() == nil) - if GHCLIImporter.locate() == nil { - Label("gh CLI not found — paste a token instead.", systemImage: "info.circle") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - } + if GHCLIImporter.locate() == nil { + Label("gh CLI not found — paste a token instead.", systemImage: "info.circle") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } - Divider() + Divider() + } VStack(alignment: .leading, spacing: 5) { - Text("Or paste a personal access token") + Text(isSandboxed ? "Connect with a personal access token" : "Or paste a personal access token") .font(.system(size: 11, weight: .medium)) SecureField("ghp_… or github_pat_…", text: $pastedToken) .textFieldStyle(.roundedBorder) .font(.system(size: 11, design: .monospaced)) .onSubmit(submit) - Text("Needs the repo scope. Stored in your login Keychain — never written to disk in the clear.") + Text(isSandboxed + ? "Needs the repo scope. The App Store version can't read a gh CLI login, so it connects with a token. Stored in your login Keychain — never written to disk in the clear." + : "Needs the repo scope. Stored in your login Keychain — never written to disk in the clear.") .font(.system(size: 10)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - Button("Connect", action: submit) - .controlSize(.small) - .disabled(pastedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + HStack(spacing: 10) { + connectButton + Button("Create a token on GitHub…") { open(Self.newTokenURL) } + .buttonStyle(.link) + .font(.system(size: 11)) + if isSandboxed, isWorking { + ProgressView().controlSize(.small).scaleEffect(0.6) + } + } } HStack { @@ -99,6 +118,21 @@ struct ConnectView: View { .padding(14) } + /// The main action in the App Store build, a secondary one elsewhere. + @ViewBuilder + private var connectButton: some View { + let isEmpty = pastedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if isSandboxed { + Button("Connect", action: submit) + .buttonStyle(.borderedProminent) + .disabled(isEmpty || isWorking) + } else { + Button("Connect", action: submit) + .controlSize(.small) + .disabled(isEmpty) + } + } + private func submit() { let token = pastedToken pastedToken = "" diff --git a/Tests/ClearedTests/ClearedTests.swift b/Tests/ClearedTests/ClearedTests.swift index 0dcb28b..45d17f8 100644 --- a/Tests/ClearedTests/ClearedTests.swift +++ b/Tests/ClearedTests/ClearedTests.swift @@ -276,6 +276,11 @@ struct TokenTests { #expect(!printed.contains("MNOPQRST")) } + @Test("the App Store build never offers the gh CLI login, even with gh installed") + func sandboxHidesGHImport() { + #expect(GHCLIImporter.locate(sandboxed: true) == nil) + } + @Test("an empty token is rejected rather than stored") func rejectsEmpty() { #expect(throws: TokenStore.TokenError.self) { @@ -338,6 +343,7 @@ struct PreviewArgumentTests { @Test("selects a fixture by case name, not by display label", arguments: [ ("full", Fixture.full), ("overflow", Fixture.overflow), ("empty", Fixture.empty), + ("connect", Fixture.connect), ("connect-appstore", Fixture.connectAppStore), ]) func selectsByKey(argument: String, expected: Fixture) { #expect(Fixture.fromCommandLine(["Cleared", "--ui-preview", argument]) == expected) diff --git a/project.yml b/project.yml index 5d35e78..9290c57 100644 --- a/project.yml +++ b/project.yml @@ -6,11 +6,20 @@ options: createIntermediateGroups: true groupSortPosition: top +# AppStore is Release plus the App Sandbox, which the Mac App Store requires. +# It is its own configuration so the direct build stays unsandboxed — the +# sandbox is what stops Cleared reading an existing `gh` login. +configs: + Debug: debug + Release: release + AppStore: release + # Cleared.xcconfig optionally includes Local.xcconfig. Pointing at # Local.xcconfig directly would fail on every clone that does not have one. configFiles: Debug: Cleared.xcconfig Release: Cleared.xcconfig + AppStore: Cleared.xcconfig settings: base: @@ -34,6 +43,17 @@ settings: ENABLE_HARDENED_RUNTIME: NO Release: ENABLE_HARDENED_RUNTIME: YES + # Signed under whichever team will submit to the store, which need not be + # the one used for day-to-day builds. Both values come from Local.xcconfig + # and fall back to ad-hoc, so this configuration still builds on a fresh + # clone and in CI. Set here rather than left to the xcconfig because the + # day-to-day DEVELOPMENT_TEAM would otherwise apply. + AppStore: + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_ENTITLEMENTS: Resources/Cleared.entitlements + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "$(CLEARED_APPSTORE_IDENTITY:default=-)" + DEVELOPMENT_TEAM: "$(CLEARED_APPSTORE_TEAM:default=)" targets: Cleared: @@ -42,6 +62,7 @@ targets: sources: - path: Sources/Cleared - path: Resources/Assets.xcassets + - path: Resources/PrivacyInfo.xcprivacy settings: base: INFOPLIST_FILE: Resources/Info.plist @@ -74,3 +95,7 @@ schemes: config: Debug targets: - ClearedTests + # Product → Archive in Xcode builds the sandboxed App Store configuration, + # so distributing to App Store Connect needs no further setup. + archive: + config: AppStore