Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions Local.xcconfig.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -219,6 +224,42 @@ make dist # writes dist/Cleared-<version>.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

```
Expand Down
13 changes: 13 additions & 0 deletions Resources/Cleared.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Used by the AppStore configuration only. The Mac App Store requires
the sandbox; Cleared needs nothing from it but outbound HTTPS to
api.github.com. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
6 changes: 5 additions & 1 deletion Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<!-- Cleared uses only the HTTPS the system provides, which is exempt.
Declaring it here saves answering the question on every upload. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>MIT licensed.</string>
<string>Copyright © 2026 Kevin Holland. MIT licensed.</string>
</dict>
</plist>
26 changes: 26 additions & 0 deletions Resources/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Cleared talks to api.github.com with the user's own token and to
nothing else. It collects nothing and tracks no one. -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<!-- UserDefaults, for the app's own settings. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
89 changes: 89 additions & 0 deletions Scripts/appstore.sh
Original file line number Diff line number Diff line change
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>$team</string>
</dict>
</plist>
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."
7 changes: 6 additions & 1 deletion Sources/Cleared/Auth/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions Sources/Cleared/Auth/TokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
36 changes: 33 additions & 3 deletions Sources/Cleared/ClearedApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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".
Expand All @@ -88,6 +106,8 @@ struct PreviewHarness: View {
case .full: "full"
case .overflow: "overflow"
case .empty: "empty"
case .connect: "connect"
case .connectAppStore: "connect-appstore"
}
}

Expand All @@ -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)
}
Expand All @@ -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))
Expand All @@ -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
Loading
Loading