JC

jkerketta/crowsnest

Developer tools
0 stars Quality 35 Trend 8

An iOS widget that displays your agents' PRs and CI updates the moment they land.

Overview

An iOS widget built with SwiftUI and WidgetKit, Apple's framework for home screen widgets, that keeps your open pull requests and their CI status one glance away so you can skip opening GitHub. Get from a fresh clone to a working widget in five steps: 1. Clone this repo and open the project in Xcode. Replace YOUR-ORG with your GitHub account: 2. Select an iPhone 18 simulator (iOS 26.2) as the run destination. 3. Build and run with Cmd+R. On first launch the app asks for a token. Paste a classic GitHub Personal Access Token (PAT) with the repo scope. The app validates it against GitHub before saving. 4. Add the widget. Long-press the home screen, tap Edit, then Add Widget. Search "Crowsnest" and pick the PRPreview widget. 5. Pull to refresh inside the app. The widget then refreshes on its own every 15 minutes. - Xcode 26 (the project targets the iOS 26.2 SDK) - A simulator or device running iOS 26.

README

Crowsnest

An iOS widget built with SwiftUI and WidgetKit, Apple’s framework for home screen widgets, that keeps your open pull requests and their CI status one glance away so you can skip opening GitHub.

Quick Start

Get from a fresh clone to a working widget in five steps:

  1. Clone this repo and open the project in Xcode. Replace YOUR-ORG with your GitHub account:
git clone [email protected]:YOUR-ORG/ios-widget.git
cd ios-widget
open ios-widget.xcodeproj
  1. Select an iPhone 18 simulator (iOS 26.2) as the run destination.
  2. Build and run with Cmd+R. On first launch the app asks for a token. Paste a classic GitHub Personal Access Token (PAT) with the repo scope. The app validates it against GitHub before saving.
  3. Add the widget. Long-press the home screen, tap Edit, then Add Widget. Search “Crowsnest” and pick the PRPreview widget.
  4. Pull to refresh inside the app. The widget then refreshes on its own every 15 minutes.

Installation

Requirements:

  • Xcode 26 (the project targets the iOS 26.2 SDK)
  • A simulator or device running iOS 26.2
  • A GitHub account with a classic PAT that has the repo scope

Build from the command line:

xcodebuild build -scheme ios-widget -destination 'generic/platform=iOS Simulator'

Run the unit test suite. The scheme’s Testable points at the ios-widgetTests target. Pick a simulator name that exists on your machine:

xcodebuild test -scheme ios-widget -destination 'platform=iOS Simulator,name=iPhone 18'

To build for a physical device, sign with your development team:

xcodebuild build -scheme ios-widget -destination 'generic/platform=iOS' -allowProvisioningUpdates

The tests use the Swift Testing framework (import Testing), not XCTest.

Usage

How it works

Crowsnest is a two-bundle app. The main app owns login and the PR list. The widget extension renders the lock screen. The two never talk directly. They share data through an App Group, the shared container that both bundles can read.

  1. Configure. The app shows a token screen until you enter a valid PAT. On save, TokenStore writes the token to the Keychain and mirrors a copy into the shared App Group container, so the widget can read it without a shared Keychain access group.
  2. Refresh. Pull to refresh, or let the app’s background transition trigger it. WidgetRefreshCoordinator fetches from the GitHub GraphQL API (GraphQL, GitHub’s typed query API), writes a PRCacheData to the App Group, then calls WidgetCenter.reloadAllTimelines().
  3. Render. The widget provider builds a timeline entry from token, cache, and fetch outcome, then shows a queue tile. A failed fetch never renders as “all caught up”. It renders as an error entry instead.

What the widget shows

The widget queries your own open PRs (is:open is:pr author:@me archived:false, first 50). It ranks non-drafts above drafts, then most recently updated first. Each row carries a stale-age clock with urgency colors: green under a day, amber one to three days, red over three days. Tapping the small tile or the medium header opens the app’s PR list through the prwidget://list deep link. Rows open the GitHub PR directly. Per-PR CI status comes from the latest commit’s status check rollup and is carried in PRData; the app list renders it, and the tile keeps the CI UI as an inert stub.

Working with the data

Fetch your open PRs:

let prs = try await GitHubService.fetchAuthoredPRs(token: token)
for pr in prs {
    print("#\(pr.number) \(pr.title) - \(pr.ciStatus.rawValue)")
}

Refresh the cache and reload widget timelines:

await WidgetRefreshCoordinator.refresh(token: token)

Store, read, and clear the token:

TokenStore.save(token: token)
if let token = TokenStore.read() {
    // use it
}
TokenStore.clear()

Cache and load PR data through the App Group:

AppGroupStore.save(PRCacheData(prs: prs, fetchedAt: Date(), status: .ok))
if let cache = AppGroupStore.load() {
    let prs = cache.prs
}

How the code is organized

Path Purpose
ios-widget/ Main app target (ios-widget.app). Token entry, PR list, deep-link routing.
PRPreviewWidget/ Widget extension target. Timeline provider and the Small, Medium, Inline, Circular, and Rectangular tiles.
Shared/ Code compiled into both targets. PRData, AppGroupStore, TokenStore, KeychainHelper, WidgetEntryBuilder, WidgetQueueLogic, PRStaleColor.
Services/ GitHubService (GraphQL client) and WidgetRefreshCoordinator.

Configuration reference

Setting Value Where
App display name Crowsnest INFOPLIST_KEY_CFBundleDisplayName
App bundle ID com.YOUR-TEAM.ios-widget.ios-widget App target
Widget bundle ID com.YOUR-TEAM.ios-widget.ios-widget.PRPreviewWidget Widget target
Widget kind PRReviewWidget PRPreviewWidget/PRPreviewWidget.swift
App Group group.com.YOUR-TEAM.ios-widget Both targets, Signing & Capabilities
Cache key prCacheData Shared/AppGroupStore.swift
Token key githubPAT Shared/AppGroupStore.swift
Deep link prwidget://list ios-widget-Info.plist
Deployment target iOS 26.2 ios-widget.xcodeproj

Replace com.YOUR-TEAM and the App Group with your own bundle identifiers in Xcode (Signing and Capabilities).

Build settings that must stay correct

  • The app target must embed the extension through an “Embed App Extensions” build phase containing PRPreviewWidget.appex. Without it, the widget does not install.
  • The widget target must set INFOPLIST_KEY_CFBundlePackageType = "XPC!" and INFOPLIST_KEY_NSExtensionPointIdentifier = "com.apple.widgetkit-extension", and keep PRPreviewWidget-Info.plist (the NSExtension dict). Missing either causes “Invalid placeholder attributes” during simulator install.
  • The app target generates its Info.plist but also keeps INFOPLIST_FILE = ios-widget-Info.plist for CFBundleURLTypes. URL schemes are not expressible with INFOPLIST_KEY_* settings, so the build system merges the two files.
  • GitHubService must decode dates with JSONDecoder.dateDecodingStrategy = .iso8601. GitHub returns ISO 8601 timestamps, and the default decoder fails on them silently.

Troubleshooting

  • Widget will not install. Re-check the Embed App Extensions phase and the widget target settings above. Then clean and rebuild.
  • Phantom build errors after switching branches. Stale DerivedData is the usual cause. Clean the project’s DerivedData:
rm -rf ~/Library/Developer/Xcode/DerivedData/ios-widget-*
  • xcodebuild test fails with SWIFT_VERSION '' or a missing Info.plist for ios-widgetTests. The test target’s build configuration is missing standard settings. Restore SWIFT_VERSION, GENERATE_INFOPLIST_FILE, PRODUCT_BUNDLE_IDENTIFIER, TEST_HOST, and BUNDLE_LOADER.

Contributing

Contributions are welcome. The default branch is fm/ios-widget-impl. Open a feature branch and a PR against it. Run the unit test suite before pushing. When you hit a sharp edge worth recording, add durable project knowledge to AGENTS.md so future contributors do not repeat it.

License

Released under the MIT License. See LICENSE.

Copyright © 2026 jkerketta

View this README on GitHub

Recommended Tools

Try a different keyword or remove a filter.

Install

npx skillfish add jkerketta/crowsnest