diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eee8473..4c5514e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,5 +50,5 @@ jobs: - name: App Store build run: | make appstore-build - codesign --display --entitlements - --xml build/appstore/Build/Products/AppStore/Cleared.app \ + codesign --display --entitlements - --xml build/appstore/Build/Products/AppStore/GitCleared.app \ | plutil -convert json -o - - | grep -q '"com.apple.security.app-sandbox":true' diff --git a/.gitignore b/.gitignore index faba082..30bf3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ Package.resolved # Optional local signing overrides (see Local.xcconfig.example) Local.xcconfig + +# App Review contact details for Scripts/asc.py — a person's phone and email +Store/review-contact.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0cd3d..675e92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.4.1 + +### Changed + +- **The app is now called GitCleared.** The bundle, its display name and every + place the interface names itself have changed. The bundle identifier has not, + so the saved token and settings carry over. `make install` removes a copy + installed under the old name. +- The README is written in the tower's own language, and shows the icon. + ## 0.4.0 The release that makes Cleared something you can leave running. diff --git a/Makefile b/Makefile index d082d24..fbf34ab 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: generate build test run install preview icon release dist appstore appstore-build scan clean +.PHONY: generate build test run install preview icon screenshots release dist appstore appstore-build scan clean -RELEASE_APP := build/Build/Products/Release/Cleared.app +RELEASE_APP := build/Build/Products/Release/GitCleared.app INSTALL_DIR ?= /Applications generate: ## Regenerate Cleared.xcodeproj from project.yml @@ -22,26 +22,33 @@ run: release ## Build Release, replace the copy in /Applications, and launch it. ## Launch at login only works from an installed copy. install: release + @pkill -x GitCleared 2>/dev/null || true + @# The app was called Cleared until 0.4.1. Take that copy away too, or + @# two would be left watching the same queue. @pkill -x Cleared 2>/dev/null || true - rm -rf "$(INSTALL_DIR)/Cleared.app" - ditto $(RELEASE_APP) "$(INSTALL_DIR)/Cleared.app" - open "$(INSTALL_DIR)/Cleared.app" - @echo "✓ Installed Cleared $$(defaults read "$(INSTALL_DIR)/Cleared.app/Contents/Info" CFBundleShortVersionString) to $(INSTALL_DIR)" + rm -rf "$(INSTALL_DIR)/GitCleared.app" "$(INSTALL_DIR)/Cleared.app" + ditto $(RELEASE_APP) "$(INSTALL_DIR)/GitCleared.app" + open "$(INSTALL_DIR)/GitCleared.app" + @echo "✓ Installed GitCleared $$(defaults read "$(INSTALL_DIR)/GitCleared.app/Contents/Info" CFBundleShortVersionString) to $(INSTALL_DIR)" ## Render the panel in a normal window against sample data. ## FIXTURE=full|overflow|empty ## Opens a second instance and only ever closes an earlier preview, so an ## installed copy that is running keeps watching. preview: build - @pkill -f 'Cleared.app/Contents/MacOS/Cleared --ui-preview' 2>/dev/null || true + @pkill -f 'GitCleared.app/Contents/MacOS/GitCleared --ui-preview' 2>/dev/null || true @open -n "$$(xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Debug \ - -showBuildSettings 2>/dev/null | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p')/Cleared.app" \ + -showBuildSettings 2>/dev/null | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p')/GitCleared.app" \ --args --ui-preview $${FIXTURE:-full} ## Redraw the app icon at every size from Scripts/make-icon.swift. icon: swift Scripts/make-icon.swift +## Frame the raw captures in Store/screenshots/raw/ for the App Store listing. +screenshots: + swift Scripts/compose-screenshots.swift + ## Build a Developer ID-signed, notarized zip in dist/. See Scripts/release.sh. dist: @./Scripts/release.sh diff --git a/README.md b/README.md index 3401b77..78e5110 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,69 @@ -# Cleared +

+ GitCleared icon: a white air traffic control tower on an amber tile +

-**Clear held deploys and green pull requests from the macOS menu bar.** +

GitCleared

-Cleared is the *write* half of a GitHub menu bar app. The excellent read-only -monitors — RepoBar, PullBar, GitBar, Deplog — all tell you something needs your -attention and then send you to a browser tab. Cleared gives you the button. +

+ Tower control for your GitHub deploys.
+ Clear held deployments for takeoff and bring green pull requests in to land,
+ from the macOS menu bar. +

-It does four things and nothing else: +--- -| | Action | GitHub API | -|---|---|---| -| ▲ | **Clear a held deployment** — approve an environment gate you're a required reviewer on | `POST /actions/runs/{id}/pending_deployments` | -| ● | **Merge a green PR** — squash-merge when checks pass and nothing conflicts | `PUT /pulls/{n}/merge` | -| ✖ | **Re-run failed jobs** | `POST /actions/runs/{id}/rerun-failed-jobs` | -| ▶ | **Start a workflow** by hand | `POST /actions/workflows/{id}/dispatches` | +Every other GitHub menu bar app is a radar screen. RepoBar, PullBar, GitBar, +Deplog — excellent, all of them — show you the traffic and then send you to a +browser tab to do anything about it. GitCleared is the tower. It issues the +clearances. + +There are four, and it does nothing else: -The queue is sorted by what blocks you, not by repository. A held production -deploy is the top item no matter which repo it's in, because that's the one -that costs real time. +| The call | The button | What it does | GitHub API | +|---|---|---|---| +| **Cleared for takeoff** | *Clear* | Approves a deployment holding at an environment gate you're a required reviewer on | `POST /actions/runs/{id}/pending_deployments` | +| **Cleared to land** | *Merge* | Merges a green pull request — squash by default — when checks pass and nothing conflicts | `PUT /pulls/{n}/merge` | +| **Go around** | *Re-run* | Re-runs the failed jobs of a run | `POST /actions/runs/{id}/rerun-failed-jobs` | +| **Push back** | *Start a workflow* | Starts a workflow by hand | `POST /actions/workflows/{id}/dispatches` | -Every button is one GitHub would honour. A pull request only gets **Merge** when -its checks are green *and* GitHub reports no review or branch rule in the way, -and the merge is pinned to the commit Cleared last saw — so a push that lands -between polls is refused, not merged unseen. **Start a workflow** lists only -workflows that declare `workflow_dispatch`, with the inputs each one's file -declares. +The strip board is ordered by what is waiting on you, not by repository. A +production deploy holding short of the runway is the top strip whichever repo +it belongs to, because that is the one burning fuel. -## Why it exists +**No clearance GitHub wouldn't honour.** A pull request is only cleared to land +when its checks are green *and* GitHub reports no review or branch rule in the +way, and the merge is pinned to the commit GitCleared last had on radar — so a +push that arrives between sweeps is refused, not merged unseen. *Start a +workflow* lists only workflows that declare `workflow_dispatch`, with the +inputs each one's file declares. -Environment gates with required reviewers don't fail — they idle. A deploy sits +## Why there's a tower + +Environment gates with required reviewers don't fail — they hold. A deploy sits in `waiting` until the one person named as a reviewer happens to look at the right browser tab. Measured across a month of one heavy user's repositories, the median gated deploy waited over half an hour to start; the worst sat for -eight days. Nothing was broken. Nobody was looking. +eight days. Nothing was broken. Nobody was in the tower. + +GitCleared puts that queue in the menu bar and makes clearing it one click. -Cleared puts that queue in the menu bar and makes clearing it one click. +## Phraseology -## Install +The panel already talks like a tower. If you'd rather it spoke GitHub: + +| On the board | In GitHub terms | +|---|---| +| **Holding** | A deployment waiting at an environment gate for your approval | +| **Ready to merge** | A pull request on final: green, approved where required, nothing in the way | +| **In flight** | An open pull request that can't land yet, with the reason — *needs review*, *checks running*, *behind base branch*, *conflicts* | +| **Failed** | A run that went wrong, with a go-around on offer | +| **All clear** | Nothing is waiting on you | + +## Preflight — installing Requires macOS 14+, and Xcode 16+ with [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`) to build. There is no signed download yet, so for now -Cleared is built from source: +GitCleared is built from source: ```sh git clone https://github.com/hollandcloud/cleared.git @@ -48,8 +71,8 @@ cd cleared make install # builds Release, copies it to /Applications, launches it ``` -Then turn on *Settings → Launch at login*. Cleared starts watching as soon as it -launches — you don't need to open the panel for holds to be noticed. +Then turn on *Settings → Launch at login*. The tower is staffed from the moment +it launches — you don't need to open the panel for a hold to be noticed. `make run` builds and launches without installing, which is fine for a look but can't be registered to launch at login. @@ -62,14 +85,17 @@ open Cleared.xcodeproj ``` `Cleared.xcodeproj` is generated and deliberately not committed — `project.yml` -is the source of truth. +is the source of truth. (The app was called Cleared until 0.4.1. The project, +the Swift module and the bundle identifier keep that name on purpose: changing +the identifier would throw away everyone's saved token and settings.) -## Connecting +## Squawk — connecting to GitHub -Cleared needs a GitHub token with the **`repo`** scope. +A tower needs to know who is calling. GitCleared 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. +- **If you use the `gh` CLI:** click *Use my gh CLI login*. GitCleared shells + out to `gh auth token` once and stores the result. - **Otherwise:** paste a personal access token. *Create a token on GitHub…* opens the new-token page with the scope already selected. @@ -78,24 +104,24 @@ 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 -in the clear, and never logged — the only representation Cleared will ever +in the clear, and never logged — the only representation GitCleared will ever display is a fingerprint like `ghp_…2345 · 40 chars`. To revoke: *Settings → Disconnect and forget token*, which deletes the Keychain item. Revoke the token itself on GitHub. If GitHub starts rejecting the token — -revoked, or expired — Cleared stops polling and returns to the connect screen -saying so, rather than failing quietly. +revoked, or expired — GitCleared stops its sweeps and returns to the connect +screen saying so, rather than going quiet on frequency. ### Why `repo` Approving a deployment, merging, re-running jobs and dispatching a workflow all require `repo` on a classic token; GitHub offers nothing narrower. (`workflow` -is not needed — that scope is for editing workflow files, which Cleared never -does.) +is not needed — that scope is for editing workflow files, which GitCleared +never does.) -If that's more access than you want a menu bar app to hold, use a fine-grained -token limited to the repositories you want Cleared to touch, with these -repository permissions: +If that's more authority than you want a menu bar app to hold, use a +fine-grained token limited to the repositories you want it to control, with +these repository permissions: | Permission | Access | Used for | |---|---|---| @@ -107,52 +133,53 @@ repository permissions: Organizations that enforce SAML single sign-on also need the token authorized for that organization on GitHub. -## Using it +## Working the board -- **Click a row** to open it on GitHub. **Right-click** for *Copy Link* — and, - on a held deployment, *Reject…*, which cancels the waiting jobs with a reason. -- **⌘1–⌘9** merge the first nine ready pull requests, in the order shown. - **⌘R** refreshes, **⌘,** opens Settings, **⌘Q** quits. -- A **notification** appears when a deployment starts holding, with a *Clear* - button on it. It is withdrawn once the hold is gone, whoever cleared it. -- *Settings → Ask before clearing or merging* turns each button into a - two-click *Sure?* confirmation. It is off by default: one click is the point. -- Pull requests that can't be merged yet sit under **In flight** with the - reason — *needs review*, *checks running*, *behind base branch*, *conflicts*. +- **Click a strip** to open it on GitHub. **Right-click** for *Copy Link* — and, + on a held deployment, *Reject…*, which sends it back to the gate: the waiting + jobs are cancelled, with your reason on the run. +- **⌘1–⌘9** land the first nine ready pull requests, in the order shown. + **⌘R** sweeps again, **⌘,** opens Settings, **⌘Q** closes the tower. +- **The tower calls you.** A notification appears when a deployment starts + holding, with a *Clear* button on it. It is withdrawn once the hold is gone, + whoever cleared it. +- **Read-back, if you want it.** *Settings → Ask before clearing or merging* + turns each button into a two-click *Sure?*. It is off by default: one click + is the point. -## How it stays inside the rate limit +## Flow control — staying inside the rate limit -Polling every repository every 30 seconds would burn 5,000 requests an hour on -its own. Cleared avoids that: +Sweeping every repository every 30 seconds would burn 5,000 requests an hour on +its own. GitCleared avoids that: -- **Conditional requests.** Every poll sends `If-None-Match`. GitHub does not +- **Conditional requests.** Every sweep sends `If-None-Match`. GitHub does not charge a `304` against the hourly limit, and a repository with nothing - waiting returns a byte-identical body forever — so a quiet repository is - free. + waiting returns a byte-identical body forever — so quiet airspace is free. - **One GraphQL query** covers open pull requests and their check rollups across every repository, rather than one REST call per repo. -- **Backoff.** Polling slows by 4× while the menu is closed, and by a further +- **Backing off.** Sweeps slow by 4× while the panel is closed, and by a further 4× when fewer than 500 calls remain. -- **Stopping when told to.** If GitHub answers with a rate limit, Cleared sends - nothing further on that budget until the time GitHub named, instead of +- **Honouring a ground stop.** If GitHub answers with a rate limit, GitCleared + sends nothing further on that budget until the time GitHub named, instead of retrying into it. -Remaining budget is shown in the footer. When a poll comes back short — no -network, rate limited — the footer says why and how stale the queue is. +Remaining budget is shown in the footer. When a sweep comes back short — no +network, rate limited — the footer says why and how stale the board is. -## Repository discovery +## Your airspace — which repositories it watches -Cleared ships with **no repository list**. On first connect it asks GitHub for -repositories you've pushed to in the last 90 days (configurable) and watches -the 20 most recent, re-checking hourly so a new repository is picked up without -a restart. In Settings you can ignore any of them (and un-ignore them later), or -pin a repository so it stays watched however long it has been quiet. +GitCleared ships with **no repository list**. On first connect it asks GitHub +for repositories you've pushed to in the last 90 days (configurable) and +watches the 20 most recent, re-checking hourly so a new repository comes onto +the scope without a restart. In Settings you can ignore any of them (and +un-ignore them later), or pin a repository so it stays watched however long it +has been quiet. This is deliberate: it means the source of this public repository reveals nothing about which repositories anyone actually tracks, and low-traffic repos that see one PR a month get picked up without being added by hand. -## Development +## Ground crew — development ```sh make build # debug build @@ -162,15 +189,16 @@ 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 +make preview FIXTURE=connect # ... the connect screen (connect-appstore for the store build's) make icon # redraw the app icon from Scripts/make-icon.swift make scan # refuse to commit anything token-shaped make clean ``` -Pull requests run the same `make scan`, `make test` and a Release build on -macOS 15 and macOS 26. +Pull requests run `make scan`, `make test`, a Release build and the App Store +build on macOS 15 and macOS 26. -### Tests +### Preflight checks — the tests The tests never reach GitHub and never touch your Keychain. `StubGitHub` stands in for the API, so the client's caching and rate-limit handling and the @@ -180,26 +208,27 @@ usual here: the tests run hosted inside the app, so a test that signed out for real would delete the token of whoever ran it. Token storage is injected for exactly that reason. -To watch what the running app is doing: +To listen in on what the running app is doing: ```sh log stream --predicate 'subsystem == "net.hlnd.Cleared"' --level debug ``` -### Working on the panel +### The simulator — working on the panel -Laying out the queue against live GitHub means waiting for a deployment to +Laying out the board against live GitHub means waiting for a deployment to actually be held — the rare event the whole app exists to catch. `make preview` opens the panel in an ordinary window with fixtures covering the shapes that matter: a title long enough to wrap, a gate spanning two environments, and -enough rows to push the queue past its height cap. +enough strips to push the queue past its height cap. It runs as a second +instance, so an installed copy keeps watching. ### Signing and the Keychain -Cleared signs **ad-hoc** by default so anyone can clone and build without an +GitCleared signs **ad-hoc** by default so anyone can clone and build without an Apple account. The cost is that every rebuild produces a new app identity, which invalidates the access list on the Keychain entry holding your token — so you -have to reconnect after each build. The sign-in screen says so when it detects +have to reconnect after each build. The connect screen says so when it detects that case rather than pretending no token was ever saved. To make the signature stable, copy `Local.xcconfig.example` to `Local.xcconfig` @@ -208,15 +237,15 @@ The file is optional: without it the build falls back to ad-hoc signing. Install the pre-commit secret scan with `./Scripts/install-hooks.sh`. -### Releasing +### Departures — releasing A build that other people can download has to be signed with a Developer ID and notarized, or Gatekeeper refuses to open it: ```sh DEVELOPER_ID="Developer ID Application: Your Name (TEAMID)" \ -NOTARY_PROFILE=cleared-notary \ -make dist # writes dist/Cleared-.zip +NOTARY_PROFILE=gitcleared-notary \ +make dist # writes dist/GitCleared-.zip ``` `NOTARY_PROFILE` is a credential profile saved once with @@ -244,9 +273,9 @@ 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 +signed package to `dist/appstore/`, 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: @@ -260,13 +289,13 @@ What differs in the sandboxed build: `make appstore-build` builds that configuration without archiving and needs no signing set up. CI runs it on every pull request. -### Layout +### Hangar layout ``` Sources/Cleared/ ├── Auth/ Keychain wrapper, token store, gh CLI import ├── GitHub/ Actor-isolated client (ETag cache, rate limits), the four -│ verbs, and the reader for workflow_dispatch inputs +│ clearances, and the reader for workflow_dispatch inputs ├── Models/ Sendable value types ├── Queue/ AppModel — the polling loop, actions, banner state ├── Support/ Settings, notifications, launch at login, logging @@ -279,3 +308,6 @@ dependencies. ## License MIT — see [LICENSE](LICENSE). + +GitCleared is an independent project. It is not affiliated with, endorsed by or +sponsored by GitHub, the Git project or Software Freedom Conservancy. diff --git a/Resources/Info.plist b/Resources/Info.plist index 1381704..7bc3ee4 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -12,13 +12,15 @@ 6.0 CFBundleName $(PRODUCT_NAME) + CFBundleDisplayName + $(PRODUCT_NAME) CFBundlePackageType APPL CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) - ITSAppUsesNonExemptEncryption diff --git a/Scripts/appstore.sh b/Scripts/appstore.sh index 9133e20..8450fcf 100755 --- a/Scripts/appstore.sh +++ b/Scripts/appstore.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Archives Cleared for the Mac App Store and exports the signed package that +# Archives GitCleared for the Mac App Store and exports the signed package that # App Store Connect accepts. # # make appstore @@ -30,11 +30,11 @@ if [ -z "$team" ]; then exit 1 fi -archive="$HOME/Library/Developer/Xcode/Archives/$(date +%Y-%m-%d)/Cleared $version ($build) $(date +%H.%M).xcarchive" +archive="$HOME/Library/Developer/Xcode/Archives/$(date +%Y-%m-%d)/GitCleared $version ($build) $(date +%H.%M).xcarchive" out=dist/appstore options=build/ExportOptions.plist -echo "▸ Archiving Cleared $version ($build) with the AppStore configuration" +echo "▸ Archiving GitCleared $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 \ @@ -73,7 +73,7 @@ pkgutil --check-signature "$pkg" | sed -n '1,6p' 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) +app=$(find "$work/pkg" -maxdepth 4 -name 'GitCleared.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'*) ;; diff --git a/Scripts/asc.py b/Scripts/asc.py new file mode 100755 index 0000000..7170a7f --- /dev/null +++ b/Scripts/asc.py @@ -0,0 +1,862 @@ +#!/usr/bin/env python3 +"""Push the store listing in Store/ to App Store Connect. + +Adapted from the script of the same name in hollandcloud/weatherfeels (same +author, same licence). The differences are the bundle identifier, that this +app ships on one platform, and that the App Review contact is read from a +local file rather than written into a public repository. + +Needs an App Store Connect API key with App Manager rights: + + export ASC_KEY_ID=XXXXXXXXXX # the .p8's key id + export ASC_ISSUER_ID= # Users and Access -> Integrations + # key read from ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8 + +Then: + + Scripts/asc.py status # what ASC currently has. Run this first. + Scripts/asc.py version 1.1.1 # open a new version once the last one is on sale + Scripts/asc.py text # name, subtitle, description, keywords, URLs + # release notes come from + # release_notes-.txt where present + Scripts/asc.py screenshots # upload Store/screenshots// + Scripts/asc.py review # App Review contact details and notes + Scripts/asc.py agerating # the questionnaire, all answers "none" + Scripts/asc.py attach [build] # point the editable versions at a build + # (defaults to CURRENT_PROJECT_VERSION) + Scripts/asc.py submit # put them in front of App Review + Scripts/asc.py all + +`status` is read-only; run it first and after anything else. It prints which platforms +exist on the record and the `screenshotDisplayType` of every set, which is how the +constants below were confirmed rather than assumed. + +`submit` cannot rescue a rejected submission. Once App Review returns a submission with +UNRESOLVED_ISSUES, the API will neither resubmit it ("Version is not ready to be +submitted yet", forever) nor let the version out of it ("Item was already submitted"). +That has to be closed out in Resolution Center in the browser first; everything up to +and including attaching the new build can be done from here. + +`text` will happily overwrite a name or subtitle edited in the browser, because the +files under Store/metadata are the source of truth. If someone has changed either in +App Store Connect, copy it back into the .txt first — `status` shows the current value. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from base64 import urlsafe_b64encode +from pathlib import Path + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils + +API = "https://api.appstoreconnect.apple.com" +BUNDLE_ID = "net.hlnd.Cleared" +ROOT = Path(__file__).resolve().parent.parent +STORE = ROOT / "Store" +LOCALE = "en-US" + +# ASC platform values, and the directory under Store/screenshots each one draws from. +PLATFORMS = { + "MAC_OS": ["mac"], +} + +# `appScreenshotSet.screenshotDisplayType` per directory. +DISPLAY_TYPES = { + "mac": "APP_DESKTOP", +} + + +def token() -> str: + key_id = os.environ.get("ASC_KEY_ID") + issuer = os.environ.get("ASC_ISSUER_ID") + if not key_id or not issuer: + sys.exit("set ASC_KEY_ID and ASC_ISSUER_ID (see the docstring)") + + path = Path( + os.environ.get( + "ASC_KEY_PATH", + Path.home() / ".appstoreconnect/private_keys" / f"AuthKey_{key_id}.p8", + ) + ) + if not path.exists(): + sys.exit(f"no private key at {path}") + + key = serialization.load_pem_private_key(path.read_bytes(), password=None) + + def segment(payload: dict) -> bytes: + raw = json.dumps(payload, separators=(",", ":")).encode() + return urlsafe_b64encode(raw).rstrip(b"=") + + header = segment({"alg": "ES256", "kid": key_id, "typ": "JWT"}) + now = int(time.time()) + claims = segment( + {"iss": issuer, "iat": now, "exp": now + 20 * 60, "aud": "appstoreconnect-v1"} + ) + signing_input = header + b"." + claims + + # ASC wants JOSE (r || s), not the DER sequence `cryptography` returns. + der = key.sign(signing_input, ec.ECDSA(hashes.SHA256())) + r, s = asym_utils.decode_dss_signature(der) + jose = r.to_bytes(32, "big") + s.to_bytes(32, "big") + return (signing_input + b"." + urlsafe_b64encode(jose).rstrip(b"=")).decode() + + +JWT = None + + +class ASCError(Exception): + """An HTTP error from ASC, with the body kept so a caller can act on it.""" + + def __init__(self, status: int, body: str): + super().__init__(f"HTTP {status}: {body}") + self.status = status + self.body = body + + def uneditable_attribute(self) -> str | None: + """The attribute ASC refused, if it refused one. + + A first version has no "What's New" — it is only meaningful on an update — and + ASC reports that as a 409 naming the attribute rather than ignoring it. Reading + the name back out lets the caller drop that one field and keep the rest, instead + of the whole listing failing on a field that was never going to apply. + + Apple words this refusal two different ways, and which one you get depends on the + field. `privacyPolicyUrl` on a live version gives the second, so matching only the + first meant the whole run died on it once 1.0 was on sale. + """ + for pattern in ( + r"Attribute '(\w+)' cannot be edited", + r"field '(\w+)' can ?not be modified", + ): + match = re.search(pattern, self.body) + if match: + return match.group(1) + return None + + +def call(method: str, path: str, body: dict | None = None, raw: bytes | None = None, + headers: dict | None = None, tolerate: bool = False) -> dict: + global JWT + if JWT is None: + JWT = token() + + url = path if path.startswith("http") else API + path + data = raw if raw is not None else (json.dumps(body).encode() if body else None) + request = urllib.request.Request(url, data=data, method=method) + + # The bearer token goes to the API and nowhere else. Screenshot bytes are PUT to + # Apple's object storage on a presigned URL whose signature covers `host` alone, so an + # extra Authorization header makes it a different request than the one that was signed + # and the upload comes back 400 "Invalid request". + if url.startswith(API): + request.add_header("Authorization", f"Bearer {JWT}") + if raw is None and data: + request.add_header("Content-Type", "application/json") + for name, value in (headers or {}).items(): + request.add_header(name, value) + + try: + with urllib.request.urlopen(request) as response: + payload = response.read() + return json.loads(payload) if payload else {} + except urllib.error.HTTPError as error: + detail = error.read().decode(errors="replace") + if tolerate: + raise ASCError(error.code, detail) from None + # ASC's errors are specific and worth showing verbatim — they name the field. + sys.exit(f"{method} {url}\n HTTP {error.code}\n {detail}") + + +def read(name: str) -> str | None: + path = STORE / "metadata" / LOCALE / f"{name}.txt" + return path.read_text().strip() if path.exists() else None + + +def app_id() -> str: + apps = call("GET", f"/v1/apps?filter[bundleId]={BUNDLE_ID}")["data"] + if not apps: + sys.exit(f"no app record for {BUNDLE_ID}") + return apps[0]["id"] + + +def status() -> None: + app = app_id() + info = call("GET", f"/v1/apps/{app}")["data"]["attributes"] + print(f"app {app} {info.get('name')} sku={info.get('sku')}") + print(f" primary locale: {info.get('primaryLocale')}") + + versions = call( + "GET", + f"/v1/apps/{app}/appStoreVersions?limit=200" + "&fields[appStoreVersions]=platform,versionString,appStoreState,createdDate", + )["data"] + seen = set() + print("\n versions:") + for version in versions: + attributes = version["attributes"] + seen.add(attributes["platform"]) + print( + f" {attributes['platform']:<7} {attributes['versionString']:<6} " + f"{attributes['appStoreState']:<28} {version['id']}" + ) + missing = set(PLATFORMS) - seen + if missing: + print( + f"\n NO VERSION FOR: {', '.join(sorted(missing))}" + "\n A platform has to be added to the app record in App Store Connect" + "\n (App Store tab -> the + beside iOS App) before builds or metadata for" + "\n it are accepted. That cannot be done through this API." + ) + + print("\n existing screenshot sets (confirm DISPLAY_TYPES against these):") + for version in versions: + localizations = call( + "GET", f"/v1/appStoreVersions/{version['id']}/appStoreVersionLocalizations" + )["data"] + for localization in localizations: + sets = call( + "GET", + f"/v1/appStoreVersionLocalizations/{localization['id']}/appScreenshotSets", + )["data"] + for screenshot_set in sets: + kind = screenshot_set["attributes"]["screenshotDisplayType"] + shots = call( + "GET", f"/v1/appScreenshotSets/{screenshot_set['id']}/appScreenshots" + )["data"] + print( + f" {version['attributes']['platform']:<7} " + f"{localization['attributes']['locale']:<6} {kind:<28} " + f"{len(shots)} image(s)" + ) + + +def editable_versions() -> dict[str, str]: + """The version each platform's metadata should be written to, keyed by platform.""" + app = app_id() + versions = call( + "GET", + f"/v1/apps/{app}/appStoreVersions?limit=200" + "&fields[appStoreVersions]=platform,versionString,appStoreState", + )["data"] + # States where the listing can still be edited. Anything else is locked and would + # need a new version created, which is deliberately left to a human. + open_states = { + "PREPARE_FOR_SUBMISSION", + "DEVELOPER_REJECTED", + "REJECTED", + "METADATA_REJECTED", + "WAITING_FOR_REVIEW", + } + found = {} + for version in versions: + attributes = version["attributes"] + if attributes["appStoreState"] in open_states: + found.setdefault(attributes["platform"], version["id"]) + return found + + +def push_text() -> None: + app = app_id() + + # Name, subtitle and the privacy policy URL hang off appInfos, not off a version. + for info in call("GET", f"/v1/apps/{app}/appInfos")["data"]: + for localization in call( + "GET", f"/v1/appInfos/{info['id']}/appInfoLocalizations" + )["data"]: + if localization["attributes"]["locale"] != LOCALE: + continue + attributes = { + "name": read("name"), + "subtitle": read("subtitle"), + "privacyPolicyUrl": read("privacy_policy_url"), + } + attributes = {k: v for k, v in attributes.items() if v} + if not attributes: + continue + + # Same drop-and-retry as the per-version fields below, and for the same + # reason: an app with a live version has more than one `appInfo`, and the one + # attached to what is on sale is frozen. Patching it used to abort the whole + # run — including the description and release notes for the version actually + # being prepared, which is the part that matters. + dropped = [] + while attributes: + try: + call( + "PATCH", + f"/v1/appInfoLocalizations/{localization['id']}", + { + "data": { + "type": "appInfoLocalizations", + "id": localization["id"], + "attributes": attributes, + } + }, + tolerate=True, + ) + break + except ASCError as error: + name = error.uneditable_attribute() + if name is None or name not in attributes: + print(f" appInfo {localization['id']}: skipped — {error}") + attributes = {} + break + del attributes[name] + dropped.append(name) + + if attributes: + note = f" (locked: {', '.join(dropped)})" if dropped else "" + print(f" appInfo {localization['id']}: {', '.join(attributes)}{note}") + + # Everything else is per-version, and therefore per-platform. + for platform, version_id in editable_versions().items(): + for localization in call( + "GET", f"/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations" + )["data"]: + if localization["attributes"]["locale"] != LOCALE: + continue + attributes = { + "description": read("description"), + "keywords": read("keywords"), + "promotionalText": read("promotional_text"), + # Per platform where a file exists, falling back to the shared one. + # What is new is genuinely different per platform here: the television is + # the headline on iPhone and iPad, is reachable on a Mac only by making + # the window tall, and does not exist on Apple TV, which never has a + # portrait screen. One set of notes for all three meant the Apple TV + # listing opening with "Turn your iPhone or iPad upright". + "whatsNew": read(f"release_notes-{platform}") or read("release_notes"), + "supportUrl": read("support_url"), + "marketingUrl": read("marketing_url"), + } + attributes = {k: v for k, v in attributes.items() if v} + + # Retried with the offending field removed rather than failing outright: on a + # first version ASC refuses `whatsNew`, and losing the description over a + # release note that cannot exist yet would be a poor trade. + dropped = [] + while attributes: + try: + call( + "PATCH", + f"/v1/appStoreVersionLocalizations/{localization['id']}", + { + "data": { + "type": "appStoreVersionLocalizations", + "id": localization["id"], + "attributes": attributes, + } + }, + tolerate=True, + ) + break + except ASCError as error: + name = error.uneditable_attribute() + if name is None or name not in attributes: + sys.exit(f" {platform}: {error}") + del attributes[name] + dropped.append(name) + + note = f" (not editable yet: {', '.join(dropped)})" if dropped else "" + print(f" {platform}: {', '.join(attributes)}{note}") + + +def reviewer_notes() -> str | None: + """The reviewer-facing block out of `review-notes.md`. + + Taken from between the first pair of `---` fences in that file, so the prose and the + table around it stay readable as documentation while the part a reviewer actually + sees has exactly one home. + """ + path = STORE / "metadata" / "review-notes.md" + if not path.exists(): + return None + parts = path.read_text().split("\n---\n") + return parts[1].strip() if len(parts) > 2 else None + + +CONTACT_FILE = STORE / "review-contact.local.json" + + +def review_contact() -> dict: + """Who App Review should call, from a file that is deliberately not committed. + + A reviewer needs a name, a phone number and an email address. Those belong to a + person, and this repository is public, so they live in a gitignored file beside + the listing instead of in the source. + """ + if not CONTACT_FILE.exists(): + sys.exit( + f"no {CONTACT_FILE.relative_to(ROOT)} — create it with firstName, lastName, " + "phone and email (see Store/README.md)" + ) + details = json.loads(CONTACT_FILE.read_text()) + return { + "contactFirstName": details["firstName"], + "contactLastName": details["lastName"], + "contactPhone": details["phone"], + "contactEmail": details["email"], + # There is no username and password to give: the app signs in with a GitHub + # token, which goes in the notes. + "demoAccountRequired": False, + } + + +def push_review() -> None: + """App Review Information: who to contact, and what to tell them.""" + contact = review_contact() + notes = reviewer_notes() + if notes: + contact["notes"] = notes + + for platform, version_id in editable_versions().items(): + existing = call( + "GET", f"/v1/appStoreVersions/{version_id}/appStoreReviewDetail" + ).get("data") + + if existing: + call( + "PATCH", + f"/v1/appStoreReviewDetails/{existing['id']}", + { + "data": { + "type": "appStoreReviewDetails", + "id": existing["id"], + "attributes": contact, + } + }, + ) + else: + call( + "POST", + "/v1/appStoreReviewDetails", + { + "data": { + "type": "appStoreReviewDetails", + "attributes": contact, + "relationships": { + "appStoreVersion": { + "data": {"type": "appStoreVersions", "id": version_id} + } + }, + } + }, + ) + print(f" {platform}: contact set{', notes ' + str(len(notes)) + ' chars' if notes else ''}") + + +def push_age_rating() -> None: + """The age-rating questionnaire: every content answer is "none".""" + # Field names and types as the API spells them. Frequency questions take an enum, + # does-it-contain questions take a boolean, and the API is strict about which is + # which — sending "NONE" for a boolean is a 409, not a coercion. The newer half of + # this list (advertising through userGeneratedContent) is required now and was not + # when this was first written, so the types were read back off the API's own + # complaints rather than guessed. + answers = { + # Frequency: NONE | INFREQUENT_OR_MILD | FREQUENT_OR_INTENSE + "alcoholTobaccoOrDrugUseOrReferences": "NONE", + "contests": "NONE", + "gamblingSimulated": "NONE", + "gunsOrOtherWeapons": "NONE", + "horrorOrFearThemes": "NONE", + "matureOrSuggestiveThemes": "NONE", + "medicalOrTreatmentInformation": "NONE", + "profanityOrCrudeHumor": "NONE", + "sexualContentGraphicAndNudity": "NONE", + "sexualContentOrNudity": "NONE", + "violenceCartoonOrFantasy": "NONE", + "violenceRealistic": "NONE", + "violenceRealisticProlongedGraphicOrSadistic": "NONE", + # Presence: all false. No advertising, no chat, nothing user-submitted, no + # in-app purchases of any kind, and nothing that needs an age check. + "advertising": False, + "ageAssurance": False, + "gambling": False, + "healthOrWellnessTopics": False, + "lootBox": False, + "messagingAndChat": False, + "parentalControls": False, + "socialMedia": False, + "unrestrictedWebAccess": False, + "userGeneratedContent": False, + } + + for info in call("GET", f"/v1/apps/{app_id()}/appInfos")["data"]: + declaration = call( + "GET", f"/v1/appInfos/{info['id']}/ageRatingDeclaration" + ).get("data") + if not declaration: + print(f" appInfo {info['id']}: no age rating declaration") + continue + + remaining = dict(answers) + dropped = [] + while remaining: + try: + call( + "PATCH", + f"/v1/ageRatingDeclarations/{declaration['id']}", + { + "data": { + "type": "ageRatingDeclarations", + "id": declaration["id"], + "attributes": remaining, + } + }, + tolerate=True, + ) + break + except ASCError as error: + # Apple renames these occasionally; drop what it rejects by name and keep + # the rest rather than losing the whole questionnaire to one stale key. + bad = re.findall(r"'?/?data/attributes/(\w+)", error.body) + bad = [name for name in bad if name in remaining] + if not bad: + sys.exit(f" age rating: {error}") + for name in bad: + del remaining[name] + dropped.append(name) + + note = f" (rejected: {', '.join(dropped)})" if dropped else "" + print(f" appInfo {info['id']}: {len(remaining)} answers set{note}") + + +def project_build_version() -> str | None: + """`CURRENT_PROJECT_VERSION` from project.yml — the build this tree produces.""" + match = re.search( + r'CURRENT_PROJECT_VERSION:\s*"([^"]+)"', (ROOT / "project.yml").read_text() + ) + return match.group(1) if match else None + + +def latest_build(app: str, platform: str, version: str | None = None) -> dict | None: + """The newest processed build for one platform, or a named one. + + Filtered by platform, which is not optional: a build belongs to exactly one, and an + App Store version will only accept its own. Picking the newest build overall and + attaching it to all three versions — which is what this did — can only ever succeed + for whichever platform happened to upload last, and fails the other two with "The + specified build has a different platform than the version". That went unnoticed while + only iOS had builds. + """ + query = ( + f"/v1/builds?filter[app]={app}" + f"&filter[preReleaseVersion.platform]={platform}" + "&limit=20&sort=-uploadedDate" + "&fields[builds]=version,processingState,uploadedDate" + ) + for build in call("GET", query)["data"]: + attributes = build["attributes"] + if attributes.get("processingState") != "VALID": + continue + if version is None or attributes.get("version") == version: + return build + return None + + +def attach_build(version: str | None = None) -> None: + """Point each editable version at the build this working tree produced. + + Defaulting to `CURRENT_PROJECT_VERSION` rather than to whatever uploaded most recently, + because "most recent" is wrong in the one case that matters. Apple processes the three + platforms at their own pace, so minutes after an upload the newest *processed* build for + a platform can still be the previous release's — and that one is already attached to a + version on sale, so ASC refuses it with "The specified pre-release build could not be + added". The failure at least made itself known here; the same fallback silently + attaching a superseded build to a new version would not have. + """ + app = app_id() + version = version or project_build_version() + if version: + print(f" attaching build {version}") + waiting = [] + + for platform, version_id in editable_versions().items(): + build = latest_build(app, platform, version) + if not build: + # Not fatal, and usually just a matter of time: Apple takes several minutes to + # process an upload, and the three platforms finish at their own pace. Report + # and carry on, so the ones that are ready get attached. + waiting.append(platform) + print(f" {platform}: build {version or 'latest'} has not finished processing") + continue + + call( + "PATCH", + f"/v1/appStoreVersions/{version_id}/relationships/build", + {"data": {"type": "builds", "id": build["id"]}}, + ) + print(f" {platform}: build {build['attributes']['version']} attached") + + if waiting: + print(f"\nStill processing: {', '.join(waiting)}. Re-run `attach` in a few minutes.") + + +def submit_for_review() -> None: + """Put the editable versions in front of App Review. + + Two steps, because a submission is a container: create one for the platform, add + the version to it as an item, then flip it to submitted. Re-running is safe — an + existing submission for the platform is reused rather than duplicated. + """ + app = app_id() + existing = { + item["attributes"]["platform"]: item + for item in call( + "GET", f"/v1/reviewSubmissions?filter[app]={app}&filter[state]=READY_FOR_REVIEW" + )["data"] + } + + for platform, version_id in editable_versions().items(): + submission = existing.get(platform) + if submission is None: + submission = call( + "POST", + "/v1/reviewSubmissions", + { + "data": { + "type": "reviewSubmissions", + "attributes": {"platform": platform}, + "relationships": { + "app": {"data": {"type": "apps", "id": app}} + }, + } + }, + )["data"] + + call( + "POST", + "/v1/reviewSubmissionItems", + { + "data": { + "type": "reviewSubmissionItems", + "relationships": { + "reviewSubmission": { + "data": {"type": "reviewSubmissions", "id": submission["id"]} + }, + "appStoreVersion": { + "data": {"type": "appStoreVersions", "id": version_id} + }, + }, + } + }, + ) + + call( + "PATCH", + f"/v1/reviewSubmissions/{submission['id']}", + { + "data": { + "type": "reviewSubmissions", + "id": submission["id"], + "attributes": {"submitted": True}, + } + }, + ) + print(f" {platform}: submitted for review ({submission['id']})") + + +def upload_one(set_id: str, image: Path) -> None: + payload = image.read_bytes() + reservation = call( + "POST", + "/v1/appScreenshots", + { + "data": { + "type": "appScreenshots", + "attributes": { + "fileName": image.name, + "fileSize": len(payload), + }, + "relationships": { + "appScreenshotSet": { + "data": {"type": "appScreenshotSets", "id": set_id} + } + }, + } + }, + )["data"] + + for operation in reservation["attributes"]["uploadOperations"]: + offset = operation["offset"] + chunk = payload[offset:offset + operation["length"]] + call( + operation["method"], + operation["url"], + raw=chunk, + headers={h["name"]: h["value"] for h in operation.get("requestHeaders", [])}, + ) + + call( + "PATCH", + f"/v1/appScreenshots/{reservation['id']}", + { + "data": { + "type": "appScreenshots", + "id": reservation["id"], + "attributes": { + "uploaded": True, + "sourceFileChecksum": hashlib.md5(payload).hexdigest(), + }, + } + }, + ) + print(f" {image.name} ({len(payload) // 1024} KiB)") + + +def push_screenshots() -> None: + versions = editable_versions() + for platform, directories in PLATFORMS.items(): + version_id = versions.get(platform) + if not version_id: + print(f" {platform}: no editable version, skipped") + continue + + localizations = [ + item + for item in call( + "GET", + f"/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations", + )["data"] + if item["attributes"]["locale"] == LOCALE + ] + if not localizations: + print(f" {platform}: no {LOCALE} localization, skipped") + continue + localization_id = localizations[0]["id"] + + for directory in directories: + images = sorted((STORE / "screenshots" / directory).glob("*.png")) + if not images: + print(f" {platform}/{directory}: no images, skipped") + continue + + kind = DISPLAY_TYPES[directory] + existing = [ + item + for item in call( + "GET", + f"/v1/appStoreVersionLocalizations/{localization_id}/appScreenshotSets", + )["data"] + if item["attributes"]["screenshotDisplayType"] == kind + ] + if existing: + set_id = existing[0]["id"] + # Cleared rather than appended to: re-running should replace the set, not + # accumulate ten near-identical shots and then fail on the eleventh. + for shot in call( + "GET", f"/v1/appScreenshotSets/{set_id}/appScreenshots" + )["data"]: + call("DELETE", f"/v1/appScreenshots/{shot['id']}") + else: + set_id = call( + "POST", + "/v1/appScreenshotSets", + { + "data": { + "type": "appScreenshotSets", + "attributes": {"screenshotDisplayType": kind}, + "relationships": { + "appStoreVersionLocalization": { + "data": { + "type": "appStoreVersionLocalizations", + "id": localization_id, + } + } + }, + } + }, + )["data"]["id"] + + print(f" {platform}/{directory} -> {kind}") + for image in images: + upload_one(set_id, image) + + +def create_version(version_string: str) -> None: + """Open a new App Store version on every platform that does not have one. + + The last step of a release that was not scripted. Once a version goes on sale its + listing is frozen, so `editable_versions()` finds nothing and every other command here + has nothing to write to — which looks like the tool being broken rather than the app + simply having shipped. + + Safe to re-run: a platform that already has this version, editable or not, is left + alone rather than being sent a duplicate. + """ + app = app_id() + existing = {} + for version in call( + "GET", + f"/v1/apps/{app}/appStoreVersions?limit=200" + "&fields[appStoreVersions]=platform,versionString,appStoreState", + )["data"]: + attributes = version["attributes"] + existing.setdefault((attributes["platform"], attributes["versionString"]), version) + + for platform in PLATFORMS: + if (platform, version_string) in existing: + state = existing[(platform, version_string)]["attributes"]["appStoreState"] + print(f" {platform}: {version_string} already exists ({state})") + continue + + created = call( + "POST", + "/v1/appStoreVersions", + { + "data": { + "type": "appStoreVersions", + "attributes": { + "platform": platform, + "versionString": version_string, + }, + "relationships": { + "app": {"data": {"type": "apps", "id": app}} + }, + } + }, + )["data"] + print(f" {platform}: created {version_string} ({created['id']})") + + +def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else "status" + if command == "status": + status() + elif command == "text": + push_text() + elif command == "screenshots": + push_screenshots() + elif command == "review": + push_review() + elif command == "agerating": + push_age_rating() + elif command == "version": + if len(sys.argv) < 3: + sys.exit("usage: asc.py version ") + create_version(sys.argv[2]) + elif command == "attach": + attach_build(sys.argv[2] if len(sys.argv) > 2 else None) + elif command == "submit": + submit_for_review() + elif command == "all": + push_text() + push_screenshots() + push_review() + push_age_rating() + else: + sys.exit(__doc__) + + +if __name__ == "__main__": + main() diff --git a/Scripts/compose-screenshots.swift b/Scripts/compose-screenshots.swift new file mode 100755 index 0000000..4618261 --- /dev/null +++ b/Scripts/compose-screenshots.swift @@ -0,0 +1,180 @@ +#!/usr/bin/env swift +// Turns the raw panel captures in Store/screenshots/raw/ into the framed, +// captioned images the Mac App Store listing uses. +// +// swift Scripts/compose-screenshots.swift (or: make screenshots) +// +// The App Store accepts Mac screenshots at a handful of exact sizes, and a menu +// bar panel is nowhere near any of them, so each capture is placed on a +// 2880x1800 canvas beside a line saying what it shows. The captures are real: +// they come from `make preview FIXTURE=… ` with `--bare`, which draws the panel +// against the sample data and nothing else. Only the frame around them is made +// here. +// +// The app's name is read from Store/metadata/en-US/name.txt, so the wordmark on +// every image follows the listing. + +import AppKit +import ImageIO +import UniformTypeIdentifiers + +let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) +let rawDirectory = root.appendingPathComponent("Store/screenshots/raw") +let outDirectory = root.appendingPathComponent("Store/screenshots/mac") +let appName = ((try? String(contentsOf: root.appendingPathComponent("Store/metadata/en-US/name.txt"), encoding: .utf8)) ?? "GitCleared") + .trimmingCharacters(in: .whitespacesAndNewlines) + +struct Shot { + let source: String + let output: String + let headline: String + let detail: String +} + +let shots = [ + Shot(source: "queue", output: "01-queue", + headline: "Everything waiting on you.", + detail: "Held deployments first, then the pull requests that are ready to land."), + Shot(source: "all-clear", output: "02-all-clear", + headline: "Quiet when the runway is clear.", + detail: "No badge and no noise until something needs you."), + Shot(source: "start-a-workflow", output: "03-start-a-workflow", + headline: "Start a workflow by hand.", + detail: "Only the ones that can be, with the inputs each one declares."), + Shot(source: "settings", output: "04-settings", + headline: "Tuned to how you work.", + detail: "Launch at login, notifications, merge method, and which repositories to watch."), + Shot(source: "connect", output: "05-connect", + headline: "One token, kept in your Keychain.", + detail: "It talks to GitHub as you, and to nothing else."), +] + +let canvas = CGSize(width: 2880, height: 1800) +let amber = NSColor(srgbRed: 0.97, green: 0.76, blue: 0.27, alpha: 1) + +func bitmap(_ image: NSImage) -> NSBitmapImageRep? { + guard let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } + return NSBitmapImageRep(cgImage: cg) +} + +/// The capture with any blank strip below the panel removed. A bare preview +/// window is a little taller than the panel it shows. +func trimmed(_ image: NSImage) -> CGImage? { + guard let rep = bitmap(image), let cg = rep.cgImage else { return nil } + // The window's rounded corners are in the capture too, so the edges of a + // row are left out of the comparison. + let inset = 80 + func isBlank(row: Int) -> Bool { + guard let reference = rep.colorAt(x: inset, y: row) else { return false } + for x in stride(from: inset, to: rep.pixelsWide - inset, by: 6) { + guard let colour = rep.colorAt(x: x, y: row) else { return false } + if abs(colour.redComponent - reference.redComponent) > 0.02 + || abs(colour.greenComponent - reference.greenComponent) > 0.02 + || abs(colour.blueComponent - reference.blueComponent) > 0.02 { return false } + } + return true + } + var last = rep.pixelsHigh - 1 + while last > 0, isBlank(row: last) { last -= 1 } + let height = min(rep.pixelsHigh, last + 1 + 20) // the footer's own bottom padding + return cg.cropping(to: CGRect(x: 0, y: 0, width: rep.pixelsWide, height: height)) +} + +func text(_ string: String, size: CGFloat, weight: NSFont.Weight, colour: NSColor, lineHeight: CGFloat = 1.08) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineHeightMultiple = lineHeight + return NSAttributedString(string: string, attributes: [ + .font: NSFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: colour, + .paragraphStyle: paragraph, + .kern: size > 80 ? -1.5 : 0, + ]) +} + +func height(of string: NSAttributedString, width: CGFloat) -> CGFloat { + ceil(string.boundingRect(with: CGSize(width: width, height: 4000), options: [.usesLineFragmentOrigin]).height) +} + +try FileManager.default.createDirectory(at: outDirectory, withIntermediateDirectories: true) +let icon = NSImage(contentsOf: root.appendingPathComponent("docs/icon.png")) + +for shot in shots { + guard let raw = NSImage(contentsOf: rawDirectory.appendingPathComponent(shot.source + ".png")), + let capture = trimmed(raw) + else { + print("✖ missing Store/screenshots/raw/\(shot.source).png") + continue + } + + // Drawn straight into a context with no alpha channel: the store rejects + // screenshots that have one. + guard let context = CGContext( + data: nil, width: Int(canvas.width), height: Int(canvas.height), + bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { fatalError("could not create a drawing context") } + let graphics = NSGraphicsContext(cgContext: context, flipped: false) + NSGraphicsContext.current = graphics + graphics.imageInterpolation = .high + + // Night sky behind the tower, with a little of the amber coming up from below. + NSGradient(colors: [ + NSColor(srgbRed: 0.07, green: 0.09, blue: 0.13, alpha: 1), + NSColor(srgbRed: 0.12, green: 0.15, blue: 0.21, alpha: 1), + ])!.draw(in: CGRect(origin: .zero, size: canvas), angle: 90) + NSGradient(colors: [amber.withAlphaComponent(0.16), amber.withAlphaComponent(0)])! + .draw(in: CGRect(x: 0, y: 0, width: canvas.width, height: 620), angle: 90) + + // The panel, on the right. + let region = CGRect(x: 1560, y: 150, width: 1120, height: 1500) + let scale = min(region.width / CGFloat(capture.width), region.height / CGFloat(capture.height), 1.42) + let size = CGSize(width: CGFloat(capture.width) * scale, height: CGFloat(capture.height) * scale) + let frame = CGRect(x: region.midX - size.width / 2, y: region.midY - size.height / 2, width: size.width, height: size.height) + let card = CGPath(roundedRect: frame, cornerWidth: 34, cornerHeight: 34, transform: nil) + + context.saveGState() + context.setShadow(offset: CGSize(width: 0, height: -26), blur: 70, color: NSColor.black.withAlphaComponent(0.55).cgColor) + context.addPath(card) + context.setFillColor(NSColor.white.cgColor) + context.fillPath() + context.restoreGState() + + context.saveGState() + context.addPath(card) + context.clip() + context.draw(capture, in: frame) + context.restoreGState() + + // The words, on the left, centred as a block. + let column: CGFloat = 1140 + let left: CGFloat = 220 + let headline = text(shot.headline, size: 112, weight: .bold, colour: .white, lineHeight: 1.02) + let detail = text(shot.detail, size: 50, weight: .regular, colour: NSColor.white.withAlphaComponent(0.74), lineHeight: 1.22) + let wordmark = text(appName, size: 54, weight: .semibold, colour: amber) + let iconSide: CGFloat = 132 + let headlineHeight = height(of: headline, width: column) + let detailHeight = height(of: detail, width: column) + let block = iconSide + 86 + headlineHeight + 40 + detailHeight + var y = (canvas.height + block) / 2 + + y -= iconSide + icon?.draw(in: CGRect(x: left - 14, y: y, width: iconSide, height: iconSide)) + let wordmarkHeight = height(of: wordmark, width: column) + wordmark.draw(with: CGRect(x: left + iconSide + 14, y: y + (iconSide - wordmarkHeight) / 2, width: column, height: wordmarkHeight), + options: [.usesLineFragmentOrigin]) + y -= 86 + headlineHeight + headline.draw(with: CGRect(x: left, y: y, width: column, height: headlineHeight), options: [.usesLineFragmentOrigin]) + y -= 40 + detailHeight + detail.draw(with: CGRect(x: left, y: y, width: column, height: detailHeight), options: [.usesLineFragmentOrigin]) + + graphics.flushGraphics() + + let url = outDirectory.appendingPathComponent(shot.output + ".png") + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) + else { fatalError("could not write \(url.path)") } + CGImageDestinationAddImage(destination, image, nil) + CGImageDestinationFinalize(destination) + print("✓ \(url.lastPathComponent)") +} diff --git a/Scripts/make-icon.swift b/Scripts/make-icon.swift index 0c77e74..c3d52a7 100755 --- a/Scripts/make-icon.swift +++ b/Scripts/make-icon.swift @@ -1,7 +1,9 @@ #!/usr/bin/env swift -// Draws Cleared's app icon and writes every size the asset catalog needs. +// Draws GitCleared's app icon and writes every size the asset catalog needs. // -// swift Scripts/make-icon.swift +// swift Scripts/make-icon.swift (or: make icon) +// +// Also writes docs/icon.png, the copy shown at the top of the README. // // The icon is drawn in code so it can be regenerated and reviewed like the // rest of the source: an amber tile — the colour of a held deployment — with @@ -177,4 +179,10 @@ let catalog: [String: Any] = ["info": ["author": "xcode", "version": 1]] try JSONSerialization.data(withJSONObject: catalog, options: [.prettyPrinted, .sortedKeys]) .write(to: output.deletingLastPathComponent().appendingPathComponent("Contents.json")) -print("✓ wrote \(slots.count) sizes to \(output.path)") +// A copy the README can show. GitHub cannot render an image out of an asset +// catalog, and a README without the icon does not look like the app. +let readmeIcon = URL(fileURLWithPath: "docs/icon.png") +try FileManager.default.createDirectory(at: readmeIcon.deletingLastPathComponent(), withIntermediateDirectories: true) +try png(pixels: 512).write(to: readmeIcon) + +print("✓ wrote \(slots.count) sizes to \(output.path), and \(readmeIcon.path)") diff --git a/Scripts/release.sh b/Scripts/release.sh index 75330bf..4406077 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -23,10 +23,10 @@ cd "$(git rev-parse --show-toplevel)" team=${DEVELOPER_ID##*(} team=${team%)} version=$(sed -n 's/.*MARKETING_VERSION: *"\(.*\)".*/\1/p' project.yml) -app=build/Build/Products/Release/Cleared.app -zip=dist/Cleared-$version.zip +app=build/Build/Products/Release/GitCleared.app +zip=dist/GitCleared-$version.zip -echo "▸ Building Cleared $version" +echo "▸ Building GitCleared $version" xcodegen generate >/dev/null xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Release \ -derivedDataPath build \ diff --git a/Sources/Cleared/ClearedApp.swift b/Sources/Cleared/ClearedApp.swift index eb72910..ac0c3c0 100644 --- a/Sources/Cleared/ClearedApp.swift +++ b/Sources/Cleared/ClearedApp.swift @@ -50,7 +50,14 @@ enum AppEnvironment { /// 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 + /// What the app calls itself, read from the bundle so the interface, the + /// User-Agent and the store listing cannot disagree. The code keeps its + /// original name, Cleared, for the module and the bundle identifier: + /// renaming an app is about what people see, and changing the identifier + /// would discard everyone's saved token and settings. + static let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "GitCleared" + + /// "0.4.1" — 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" } @@ -77,14 +84,26 @@ extension AppDelegate { /// deployment to actually be held — the rare event the whole app exists to /// catch — so the panel would otherwise be near-impossible to iterate on. private func openPreview() { + // `--bare` draws the panel and nothing else, edge to edge, for taking + // store screenshots: no fixture picker, no margin, no title bar. + let isBare = CommandLine.arguments.contains("--bare") let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: Theme.panelWidth + 120, height: 820), - styleMask: [.titled, .closable, .resizable], + styleMask: isBare ? [.titled, .fullSizeContentView] : [.titled, .closable, .resizable], backing: .buffered, defer: false ) - window.title = "Cleared · panel preview" - window.contentView = NSHostingView(rootView: PreviewHarness()) + window.title = "\(AppEnvironment.name) · panel preview" + let host = NSHostingView(rootView: PreviewHarness(isBare: isBare)) + window.contentView = host + if isBare { + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + window.standardWindowButton(button)?.isHidden = true + } + window.setContentSize(host.fittingSize) + } window.center() window.isReleasedWhenClosed = false window.makeKeyAndOrderFront(nil) @@ -122,8 +141,10 @@ struct PreviewHarness: View { @State private var model: AppModel @State private var fixture: Fixture + private let isBare: Bool - init() { + init(isBare: Bool = false) { + self.isBare = isBare let start = Fixture.fromCommandLine() let model = AppModel() Self.show(start, in: model) @@ -132,6 +153,19 @@ struct PreviewHarness: View { } var body: some View { + if isBare { + MenuPanel(model: model) + .environment(\.isSandboxedBuild, fixture == .connectAppStore) + .background(.background) + // The transparent title bar still reserves its strip at the + // top; the panel should start at the very edge. + .ignoresSafeArea() + } else { + framed + } + } + + private var framed: some View { VStack(spacing: 14) { Picker("", selection: $fixture) { ForEach(Fixture.allCases, id: \.self) { Text($0.rawValue).tag($0) } diff --git a/Sources/Cleared/GitHub/GitHubClient.swift b/Sources/Cleared/GitHub/GitHubClient.swift index bb665b4..b911ee5 100644 --- a/Sources/Cleared/GitHub/GitHubClient.swift +++ b/Sources/Cleared/GitHub/GitHubClient.swift @@ -11,13 +11,13 @@ enum GitHubError: Error, LocalizedError, Sendable, Equatable { var errorDescription: String? { switch self { case .notAuthenticated: - return "Connect Cleared to GitHub first." + return "Connect \(AppEnvironment.name) to GitHub first." case .http(let status, let message): switch status { case 401: return "GitHub rejected the token. Reconnect in Settings." case 403: if message.localizedCaseInsensitiveContains("rate limit") { - return "Rate limited. Cleared will back off and retry." + return "Rate limited. \(AppEnvironment.name) will back off and retry." } if message.localizedCaseInsensitiveContains("SAML") { return "That organization requires single sign-on. Authorize the token for it on GitHub." @@ -29,7 +29,7 @@ enum GitHubError: Error, LocalizedError, Sendable, Equatable { } case .rateLimited(let until): let minutes = max(1, Int((until.timeIntervalSinceNow / 60).rounded(.up))) - return "Rate limited. Cleared will retry in \(minutes)m." + return "Rate limited. \(AppEnvironment.name) will retry in \(minutes)m." case .transport(let detail): return "Couldn't reach GitHub. \(detail)" case .decoding(let detail): @@ -78,7 +78,7 @@ actor GitHubClient { config.timeoutIntervalForRequest = 20 config.waitsForConnectivity = false config.httpAdditionalHeaders = [ - "User-Agent": "Cleared/\(AppEnvironment.version) (+https://github.com/hollandcloud/cleared)", + "User-Agent": "\(AppEnvironment.name)/\(AppEnvironment.version) (+https://github.com/hollandcloud/cleared)", ] self.session = URLSession(configuration: config) } diff --git a/Sources/Cleared/Support/LoginItem.swift b/Sources/Cleared/Support/LoginItem.swift index f691cff..41412dd 100644 --- a/Sources/Cleared/Support/LoginItem.swift +++ b/Sources/Cleared/Support/LoginItem.swift @@ -9,6 +9,11 @@ enum LoginItem { /// macOS registers the app by where it lives, so a copy run straight out /// of a build folder would register a path the next build deletes. static var isInstalled: Bool { +#if DEBUG + // Store screenshots are taken from a build folder but have to show + // what an installed copy shows. + if CommandLine.arguments.contains("--bare") { return true } +#endif let path = Bundle.main.bundlePath return path.hasPrefix("/Applications/") || path.hasPrefix(NSHomeDirectory() + "/Applications/") } diff --git a/Sources/Cleared/UI/ConnectView.swift b/Sources/Cleared/UI/ConnectView.swift index a16d5f8..13f760d 100644 --- a/Sources/Cleared/UI/ConnectView.swift +++ b/Sources/Cleared/UI/ConnectView.swift @@ -14,12 +14,12 @@ struct ConnectView: View { @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")! + private static let newTokenURL = URL(string: "https://github.com/settings/tokens/new?scopes=repo&description=\(AppEnvironment.name)")! var body: some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 3) { - Text("Cleared").font(.system(size: 15, weight: .semibold)) + Text(AppEnvironment.name).font(.system(size: 15, weight: .semibold)) Text("Clear held deploys and green PRs without opening a browser.") .font(.system(size: 11)) .foregroundStyle(.secondary) diff --git a/Sources/Cleared/UI/MenuBarLabel.swift b/Sources/Cleared/UI/MenuBarLabel.swift index 1027677..1607bc2 100644 --- a/Sources/Cleared/UI/MenuBarLabel.swift +++ b/Sources/Cleared/UI/MenuBarLabel.swift @@ -31,15 +31,15 @@ struct MenuBarLabel: View { // The icon already distinguishes these; the label has to agree with it, // or VoiceOver says "all clear" over a warning triangle. switch model.phase { - case .needsToken: return "Cleared. Not connected to GitHub." - case .connecting: return "Cleared. Connecting." - case .failed: return "Cleared. Couldn't reach GitHub." + case .needsToken: return "\(AppEnvironment.name). Not connected to GitHub." + case .connecting: return "\(AppEnvironment.name). Connecting." + case .failed: return "\(AppEnvironment.name). Couldn't reach GitHub." case .ready: break } - if model.approvals.isEmpty && model.readyToMerge.isEmpty { return "Cleared. All clear." } + if model.approvals.isEmpty && model.readyToMerge.isEmpty { return "\(AppEnvironment.name). All clear." } var parts: [String] = [] if !model.approvals.isEmpty { parts.append("\(model.approvals.count) holding") } if !model.readyToMerge.isEmpty { parts.append("\(model.readyToMerge.count) ready to merge") } - return "Cleared. " + parts.joined(separator: ", ") + return "\(AppEnvironment.name). " + parts.joined(separator: ", ") } } diff --git a/Sources/Cleared/UI/MenuPanel.swift b/Sources/Cleared/UI/MenuPanel.swift index 39cf733..3de7210 100644 --- a/Sources/Cleared/UI/MenuPanel.swift +++ b/Sources/Cleared/UI/MenuPanel.swift @@ -237,8 +237,8 @@ struct MenuPanel: View { } .buttonStyle(.plain) .keyboardShortcut("q", modifiers: .command) - .help("Quit Cleared (⌘Q)") - .accessibilityLabel("Quit Cleared") + .help("Quit \(AppEnvironment.name) (⌘Q)") + .accessibilityLabel("Quit \(AppEnvironment.name)") } .padding(.horizontal, 10) .padding(.vertical, 7) diff --git a/Sources/Cleared/UI/SettingsView.swift b/Sources/Cleared/UI/SettingsView.swift index c770bfc..62d6f64 100644 --- a/Sources/Cleared/UI/SettingsView.swift +++ b/Sources/Cleared/UI/SettingsView.swift @@ -19,7 +19,7 @@ struct SettingsView: View { .interpolation(.high) .frame(width: 30, height: 30) .accessibilityHidden(true) - Text("Cleared").font(.system(size: 13, weight: .semibold)) + Text(AppEnvironment.name).font(.system(size: 13, weight: .semibold)) Spacer() if let login = model.login { Text("@\(login)").font(.system(size: 11)).foregroundStyle(.secondary) @@ -44,7 +44,7 @@ struct SettingsView: View { Divider() HStack { - Text("Cleared \(AppEnvironment.version)") + Text("\(AppEnvironment.name) \(AppEnvironment.version)") .font(.system(size: 10)) .foregroundStyle(.secondary) Spacer() @@ -66,7 +66,7 @@ struct SettingsView: View { if let loginItemError { caption(loginItemError) } else if !LoginItem.isInstalled { - caption("Move Cleared to Applications to turn this on (make install does it). A copy in a build folder would register a path the next build removes.") + caption("Move \(AppEnvironment.name) to Applications to turn this on (make install does it). A copy in a build folder would register a path the next build removes.") } } } diff --git a/Store/README.md b/Store/README.md new file mode 100644 index 0000000..d174925 --- /dev/null +++ b/Store/README.md @@ -0,0 +1,121 @@ +# App Store listing + +Everything that goes on the Mac App Store page, plus the script that pushes it. +The layout and the script follow `hollandcloud/weatherfeels`. Read **Before you +submit** first: two things in it are decisions, not tasks. + +``` +Store/ + README.md this file + metadata/ + en-US/ one file per App Store Connect field + name.txt 30 char limit — 10 used + subtitle.txt 30 — 30 + promotional_text.txt 170 — 165 + keywords.txt 100 — 98 + description.txt 4000 — 2596 + release_notes.txt 4000 — what changed; ignored on a first version + support_url.txt docs/support.md in this repo + privacy_policy_url.txt docs/privacy.md in this repo + marketing_url.txt the repo itself + review-notes.md the block between the --- fences goes to App Review + app-privacy.md the App Privacy questionnaire answers + age-rating.md the age-rating questionnaire answers + screenshots/ + raw/ the panel, captured from the preview harness + mac/ 2880x1800 the same captures, framed and captioned + review-contact.local.json who App Review should call — gitignored, see below + +docs/ + privacy.md the published privacy policy + support.md the published support page +``` + +## Before you submit + +**1. The name.** The app was renamed to GitCleared in 0.4.1. The Git trademark +policy (git-scm.com/about/trademark, section 2.3) says the mark may not be used +"as a syllable in a new word or as part of a portmanteau (e.g., 'Gitalicious', +'Gitpedia') used as a mark for a third-party product or service", and names +GitHub and GitLab as exceptions under their own licences. "GitCleared" is that +pattern. The form the policy allows without permission is "[Product Name] for +Git"; permission for anything else is asked of Software Freedom Conservancy. + +Plenty of shipping products have names like this, so it may never matter. It is +written down here because App Review is where a trademark holder's complaint +lands, and because weatherfeels was renamed before submission for a comparable +reason. Changing course is cheap: the name comes from `PRODUCT_NAME` in +`project.yml` and `name.txt` here, and everything else follows — including the +wordmark on the screenshots, which `make screenshots` redraws. + +**2. The reviewer needs a way in.** GitCleared does nothing until it has a GitHub +token, so App Review has to be given one. `metadata/review-notes.md` has a +placeholder on its TOKEN line and the steps for making a suitable token: a +throwaway account, one repository, a fine-grained token scoped to it. Submitting +with the placeholder still in place gets a rejection for "unable to sign in". + +## What still needs you + +| Thing | Why it is not done | +|---|---| +| The two decisions above | They are yours. | +| App Privacy answers | The questionnaire is browser-only. `metadata/app-privacy.md` has the answer and the reasoning. | +| Price set to Free | The description says free and open source. The pricing page has to agree. | +| Submitting for review | Deliberately left to a person. `Scripts/asc.py submit` exists for when you are ready. | + +## Pushing it + +```sh +export ASC_KEY_ID=… # the key for the team that owns the app record +export ASC_ISSUER_ID= # App Store Connect → Users and Access → + # Integrations. Not stored in this repo. + +Scripts/asc.py status # read-only. run this first, and again after. +Scripts/asc.py text # name, subtitle, description, keywords, URLs +Scripts/asc.py screenshots # replaces the set rather than appending +Scripts/asc.py review # review contact and notes +Scripts/asc.py agerating # the questionnaire, all answers "none" +Scripts/asc.py attach # point the version at the uploaded build +``` + +The key file is read from `~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8`. +It needs the `cryptography` Python package. + +`text` overwrites whatever is in App Store Connect from the files here, name and +subtitle included. If you change either in the browser, copy it back into the +`.txt` first, or the next run reverts you. + +**The review contact** — a name, a phone number and an email address — is read +from `Store/review-contact.local.json`, which is gitignored. Those belong to a +person and this repository is public. Create it once: + +```json +{ "firstName": "…", "lastName": "…", "phone": "…", "email": "…" } +``` + +Builds go up with `make appstore` and Xcode's Organizer; this script only +handles the listing. + +## How the screenshots were made + +The App Store takes Mac screenshots at a few exact sizes, and a menu bar panel +is 420 points wide, so a raw capture cannot be used as it is. Each image here is +a real capture placed on a 2880x1800 canvas beside one line saying what it +shows. + +- **The captures** come from the preview harness in its bare mode, which draws + the panel against sample data with no window chrome: + `open -n GitCleared.app --args --ui-preview full --bare`. The fixtures are + `full`, `empty`, `connect` and `connect-appstore`. Settings and the workflow + sheet are opened on top of `full` and captured with the panel dimmed behind + them, as they appear in use. +- **Each window is captured by its ID** (`screencapture -l`), never the whole + screen, so nothing else on the Mac can end up in a store image. +- **The frames** are drawn by `Scripts/compose-screenshots.swift` — `make + screenshots` — which reads the captions from its own source and the app's name + from `name.txt`. +- The repositories, pull requests and workflows shown are the sample data in + `SampleData.swift`. Nothing in them is real. + +If the panel's appearance changes, retake the captures in `raw/` and run `make +screenshots`, or the store page stops matching the app. diff --git a/Store/metadata/age-rating.md b/Store/metadata/age-rating.md new file mode 100644 index 0000000..a4d1e13 --- /dev/null +++ b/Store/metadata/age-rating.md @@ -0,0 +1,33 @@ +# Age rating answers + +App Store Connect → the app → **Age Rating**. Target: **4+**. +`Scripts/asc.py agerating` sends these. + +Every content question is answered **None** / **No**. The app shows the state of +software deployments and pull requests, and has no other content. + +| Question | Answer | +|---|---| +| Violence, of any kind | None | +| Profanity or crude humour | None | +| Mature or suggestive themes | None | +| Sexual content or nudity | None | +| Horror or fear themes | None | +| Medical or treatment information | None | +| Alcohol, tobacco or drug use or references | None | +| Simulated gambling | None | +| Contests | None | +| Gambling | No | +| Unrestricted web access | No | +| User-generated content | No | +| Messaging and chat | No | +| Advertising | No | + +On "unrestricted web access": the app opens links to github.com in the user's +own browser. It has no web view of its own, which is what the question is +about. + +On "user-generated content": pull request titles and workflow names are shown, +and they are written by people — but they are the user's own repositories' +content, fetched with the user's own credentials. The app has no feed, no +discovery and no way to see anyone else's content. diff --git a/Store/metadata/app-privacy.md b/Store/metadata/app-privacy.md new file mode 100644 index 0000000..36fab1b --- /dev/null +++ b/Store/metadata/app-privacy.md @@ -0,0 +1,44 @@ +# App Privacy answers + +App Store Connect → the app → **App Privacy**. This questionnaire is answered in +the browser; the API does not accept it. + +## Data collection + +**Do you or your third-party partners collect data from this app? → No** + +The reasoning, because it is a judgement call and the other answer is defensible: + +- Apple counts data as collected when it is sent off the device *and* the + developer or the developer's partners can access it beyond servicing the + request. +- GitCleared sends requests to `api.github.com`, authenticated with a token the + user supplies for **their own** GitHub account. Nothing is sent to the + developer. There is no server, no analytics, no crash reporting and no + advertising or attribution SDK in the app. +- GitHub is not the developer's partner here. It is the service the user + already has an account with, and the app is a client for that account — the + same position as a mail client talking to the user's own mail server. + +So nothing is collected by "you or your third-party partners", and the label +reads **Data Not Collected**. + +### If you would rather answer conservatively + +weatherfeels answered "Yes" for the coordinate it sends to weather.gov, on the +reading that any transmission counts. Applying that reading here would mean +declaring: + +| Data type | Used for | Linked to identity | Tracking | +|---|---|---|---| +| Identifiers → User ID (the GitHub account) | App Functionality | Yes | No | + +That over-describes what happens — the developer never sees the account — but +it is the answer to give if a reviewer pushes back on "No". + +## Things that are not collected, either way + +Contact info, location, contacts, user content, browsing or search history, +usage data, diagnostics, purchases, financial or health data. The token itself +is stored in the macOS Keychain and is never written to disk in the clear or +logged. diff --git a/Store/metadata/en-US/description.txt b/Store/metadata/en-US/description.txt new file mode 100644 index 0000000..7781993 --- /dev/null +++ b/Store/metadata/en-US/description.txt @@ -0,0 +1,38 @@ +A deploy that is waiting on your approval doesn't fail. It just sits there. + +GitCleared is a control tower for your GitHub deployments. It lives in the menu bar and watches the repositories you work in. The moment a deployment is held at an environment gate that needs you, it tells you, and gives you the button that clears it. No browser tab, no hunting for the right run. + +FOUR CLEARANCES, NOTHING ELSE + +• Clear a held deployment. Approve an environment gate you are a required reviewer on in one click, or reject it with a reason. +• Merge a green pull request. Only offered when checks pass, required reviews are in, and GitHub agrees nothing is in the way. +• Re-run failed jobs. Send a failed run around again. +• Start a workflow. Pick any workflow that can be run by hand and fill in the inputs it declares. + +ORDERED BY WHAT IS WAITING ON YOU + +The queue is not grouped by repository. A production deployment that has been holding for an hour is at the top, whichever repository it belongs to. Below it are the pull requests ready to merge, then the ones still in flight, each with the reason it is waiting: needs review, checks running, behind base branch, conflicts. + +ONLY BUTTONS GITHUB WILL HONOUR + +A merge is pinned to the exact commit GitCleared last saw. If someone pushes in between, GitHub refuses the merge rather than landing code nobody checked. The merge method follows your preference where a repository allows it, and the repository's own default where it does not. + +IT TELLS YOU, THEN GETS OUT OF THE WAY + +A notification appears when a deployment starts holding, with a Clear button on it, and is withdrawn once the hold is gone. When nothing is waiting, the menu bar shows a quiet tick and no badge. Turn on Launch at Login and the tower is staffed from the moment you sign in. + +LIGHT ON GITHUB'S API + +Repositories with nothing waiting cost nothing: GitCleared uses conditional requests, which GitHub does not count against your rate limit. It slows down while its panel is closed, and stops entirely when GitHub asks it to. + +PRIVATE BY DESIGN + +• Your token is stored in the macOS Keychain and sent only to api.github.com. +• No account with us, no server of ours, no analytics, no tracking. +• Open source under the MIT licence. + +WHAT YOU NEED + +A GitHub account and a personal access token with the repo scope, or a fine-grained token with Actions, Deployments, Contents and Pull requests permissions. GitCleared opens GitHub's token page for you with the right scope selected. + +GitCleared is an independent app. It is not affiliated with, endorsed by or sponsored by GitHub. diff --git a/Store/metadata/en-US/keywords.txt b/Store/metadata/en-US/keywords.txt new file mode 100644 index 0000000..9228a69 --- /dev/null +++ b/Store/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +github,deploy,deployment,approve,actions,pull request,merge,ci,cd,devops,workflow,menu bar,release diff --git a/Store/metadata/en-US/marketing_url.txt b/Store/metadata/en-US/marketing_url.txt new file mode 100644 index 0000000..e6ff7a5 --- /dev/null +++ b/Store/metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://github.com/hollandcloud/cleared diff --git a/Store/metadata/en-US/name.txt b/Store/metadata/en-US/name.txt new file mode 100644 index 0000000..fa6a69d --- /dev/null +++ b/Store/metadata/en-US/name.txt @@ -0,0 +1 @@ +GitCleared diff --git a/Store/metadata/en-US/privacy_policy_url.txt b/Store/metadata/en-US/privacy_policy_url.txt new file mode 100644 index 0000000..203782d --- /dev/null +++ b/Store/metadata/en-US/privacy_policy_url.txt @@ -0,0 +1 @@ +https://github.com/hollandcloud/cleared/blob/main/docs/privacy.md diff --git a/Store/metadata/en-US/promotional_text.txt b/Store/metadata/en-US/promotional_text.txt new file mode 100644 index 0000000..a59762a --- /dev/null +++ b/Store/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +A deploy waiting on your approval doesn't fail. It just sits there. GitCleared tells you the moment one starts holding, and clears it from the menu bar in one click. diff --git a/Store/metadata/en-US/release_notes.txt b/Store/metadata/en-US/release_notes.txt new file mode 100644 index 0000000..ddab261 --- /dev/null +++ b/Store/metadata/en-US/release_notes.txt @@ -0,0 +1 @@ +GitCleared's first release on the Mac App Store. diff --git a/Store/metadata/en-US/subtitle.txt b/Store/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..c2b249a --- /dev/null +++ b/Store/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +Tower control for your deploys diff --git a/Store/metadata/en-US/support_url.txt b/Store/metadata/en-US/support_url.txt new file mode 100644 index 0000000..7893e41 --- /dev/null +++ b/Store/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://github.com/hollandcloud/cleared/blob/main/docs/support.md diff --git a/Store/metadata/review-notes.md b/Store/metadata/review-notes.md new file mode 100644 index 0000000..4329662 --- /dev/null +++ b/Store/metadata/review-notes.md @@ -0,0 +1,70 @@ +# App Review notes + +Paste the block below into **App Review Information → Notes**, or run +`Scripts/asc.py review`, which sends it. The demo account fields stay empty: +there is no username and password. The app signs in with a GitHub token, which +goes in the notes — **replace the placeholder on the TOKEN line before +submitting** (see "What still needs you" in `Store/README.md`). + +--- + +GitCleared is a menu bar app. It has no Dock icon and no main window. After +launch, look at the right-hand side of the menu bar for its icon and click it +to open the panel. + +SIGNING IN. The app works against a GitHub account, so it needs a GitHub +personal access token. One for a test account is below. On first launch the +panel shows "Connect with a personal access token": paste the token into the +field and click Connect. + +TOKEN: + +WHAT YOU SHOULD SEE. Once connected, the panel lists what is waiting on that +account, in this order: + +• Holding — a deployment waiting at an environment gate, with a Clear button. + Clicking Clear approves the deployment on GitHub. +• Ready to merge — an open pull request whose checks have passed, with a Merge + button. +• In flight — open pull requests that cannot be merged yet, each with the + reason. +• Failed — recent failed workflow runs, each with a Re-run button. + +If the test account has nothing waiting, the panel shows "All clear". That is +the normal resting state, not an error. + +OTHER CONTROLS, along the bottom of the panel: refresh, start a workflow (the +play icon — choose a repository, then a workflow), Settings (the gear, or +Command-comma), and Quit (the power icon, or Command-Q). Right-clicking a row +offers Open on GitHub and Copy Link, and on a held deployment, Reject. + +NOTIFICATIONS. The app asks for notification permission after connecting. It +uses them only to say that a deployment has started holding. Declining changes +nothing else. + +LAUNCH AT LOGIN is off by default and is only turned on by the switch in +Settings. + +WHY THERE IS NO "USE MY GH CLI LOGIN" BUTTON. The version on GitHub can import +a login from GitHub's command-line tool. This version runs in the App Sandbox, +which does not allow that, so a pasted token is the only way to sign in. + +The app talks only to api.github.com. It has no server of its own, no +analytics and no advertising. + +--- + +## What the reviewer needs that only the owner can provide + +A token. GitHub tokens cannot be created through the API, and one for a +personal account should not be handed to App Review. The practical route: + +1. Create a throwaway GitHub account for review, or use an existing test one. +2. Give it one repository with an environment that requires that account as a + reviewer, a workflow that deploys to it, and an open pull request with a + passing check — so there is something Holding and something Ready to merge. +3. Create a fine-grained token scoped to that repository only, with Actions, + Deployments, Contents (read and write) and Pull requests (read). +4. Put it on the TOKEN line above and run `Scripts/asc.py review`. + +Revoke the token once the review is over. diff --git a/Store/screenshots/mac/01-queue.png b/Store/screenshots/mac/01-queue.png new file mode 100644 index 0000000..3208703 Binary files /dev/null and b/Store/screenshots/mac/01-queue.png differ diff --git a/Store/screenshots/mac/02-all-clear.png b/Store/screenshots/mac/02-all-clear.png new file mode 100644 index 0000000..6e4e902 Binary files /dev/null and b/Store/screenshots/mac/02-all-clear.png differ diff --git a/Store/screenshots/mac/03-start-a-workflow.png b/Store/screenshots/mac/03-start-a-workflow.png new file mode 100644 index 0000000..b0c9de5 Binary files /dev/null and b/Store/screenshots/mac/03-start-a-workflow.png differ diff --git a/Store/screenshots/mac/04-settings.png b/Store/screenshots/mac/04-settings.png new file mode 100644 index 0000000..80344a7 Binary files /dev/null and b/Store/screenshots/mac/04-settings.png differ diff --git a/Store/screenshots/mac/05-connect.png b/Store/screenshots/mac/05-connect.png new file mode 100644 index 0000000..ec1b8a0 Binary files /dev/null and b/Store/screenshots/mac/05-connect.png differ diff --git a/Store/screenshots/raw/all-clear.png b/Store/screenshots/raw/all-clear.png new file mode 100644 index 0000000..71fe018 Binary files /dev/null and b/Store/screenshots/raw/all-clear.png differ diff --git a/Store/screenshots/raw/connect.png b/Store/screenshots/raw/connect.png new file mode 100644 index 0000000..4a534b9 Binary files /dev/null and b/Store/screenshots/raw/connect.png differ diff --git a/Store/screenshots/raw/queue.png b/Store/screenshots/raw/queue.png new file mode 100644 index 0000000..1f6e4fa Binary files /dev/null and b/Store/screenshots/raw/queue.png differ diff --git a/Store/screenshots/raw/settings.png b/Store/screenshots/raw/settings.png new file mode 100644 index 0000000..409b427 Binary files /dev/null and b/Store/screenshots/raw/settings.png differ diff --git a/Store/screenshots/raw/start-a-workflow.png b/Store/screenshots/raw/start-a-workflow.png new file mode 100644 index 0000000..c0f92dc Binary files /dev/null and b/Store/screenshots/raw/start-a-workflow.png differ diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 0000000..bdc9fae Binary files /dev/null and b/docs/icon.png differ diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..c636a6d --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,60 @@ +# Privacy Policy — GitCleared + +_Last updated: 13 October 2026_ + +GitCleared has no accounts of its own, no server, and no analytics. This policy +is short because there is very little to describe. + +## What leaves your Mac + +**Requests to GitHub, as you.** GitCleared talks to one service: +**api.github.com**. It asks GitHub which of your deployments are waiting for +approval, which of your pull requests are open, and which workflow runs have +failed. When you press a button it tells GitHub to approve or reject a +deployment, merge a pull request, re-run failed jobs, or start a workflow. + +Every one of those requests is made directly from your Mac to GitHub over +HTTPS, authenticated with the access token you gave the app. We never see +them. GitHub handles them under its own privacy statement, as it does when you +use github.com. + +Nothing is sent anywhere else. There is no server of ours for it to go to. + +## What stays on your Mac + +- **Your GitHub token**, in the macOS Keychain. It is never written to disk in + the clear and never logged. *Settings → Disconnect and forget token* deletes + it. +- **Your settings** — how often to check, which repositories to ignore or pin, + your notification choices — in the app's preferences. +- **Diagnostic log lines**, in the system log on your Mac, where repository + names are marked private. They are not sent to us or to anyone. + +## What we do not do + +No analytics. No crash reporting. No advertising. No tracking across apps or +websites. No third-party SDKs of any kind — the app is built on Apple's +frameworks alone, and its source is public. + +## Notifications + +If you allow them, GitCleared shows a notification when a deployment starts +waiting on you. They are created on your Mac. Nothing is sent to a push +service. + +## Children + +GitCleared is a tool for software developers and is not directed at children. +It collects no personal information from anyone. + +## Changes + +If this policy changes, the new version will be published at this address with +a new date, and the change will be visible in the repository's history. + +## Contact + +Questions about this policy: open an issue at +https://github.com/hollandcloud/cleared/issues + +GitCleared is an independent project and is not affiliated with GitHub. diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..f19d083 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,60 @@ +# Support + +Thanks for using GitCleared. This page is the support contact for the App Store +listing. + +## Getting help or reporting a bug + +Open an issue: **https://github.com/hollandcloud/cleared/issues** + +It helps to include: + +- Your macOS version, and whether you have the App Store version or one built + from source +- What was on the panel — Holding, Ready to merge, In flight, Failed +- What you expected instead +- The line at the bottom of the panel, if it was reporting a problem + +Please do not paste a token into an issue. GitCleared never needs you to. + +## Things that are expected behaviour + +**There is no Dock icon and no window.** GitCleared lives in the menu bar. Click +its icon there to open the panel; Quit is the power button at the bottom, or +Command-Q while the panel is open. + +**The App Store version has no "Use my gh CLI login" button.** It runs in the +App Sandbox, which does not let it read a login from GitHub's command-line +tool. Paste a personal access token instead — *Create a token on GitHub…* opens +the right page with the scope selected. + +**A pull request with green checks has no Merge button.** GitCleared only +offers Merge when GitHub would accept it. The row says why it isn't: *needs +review*, *behind base branch*, *conflicts*, *blocked by branch rules*. A pull +request with no checks at all is also not offered, because a pull request whose +checks simply have not started yet looks identical. + +**A deployment is waiting on GitHub but not shown.** Only gates *you* can +approve are listed. A deployment waiting on someone else is not something you +can clear. It can also take up to two minutes to appear while the panel is +closed; opening the panel checks immediately. + +**It says "Rate limited".** GitHub asked the app to pause. It will resume on its +own at the time shown and sends nothing until then. + +**It went back to the connect screen.** GitHub rejected the token — it was +revoked or has expired. Connect again with a new one. + +**An organization's repositories are missing.** Organizations that enforce +single sign-on need the token authorized for that organization on GitHub. + +## Which token it needs + +A classic token with the **repo** scope, or a fine-grained token limited to the +repositories you choose with these permissions: Actions (read and write), +Deployments (read and write), Contents (read and write), Pull requests (read). + +## Privacy + +See the [privacy policy](privacy.md). In short: the app talks only to GitHub, +as you, and nothing is sent to us. diff --git a/project.yml b/project.yml index 9290c57..d7d352b 100644 --- a/project.yml +++ b/project.yml @@ -25,8 +25,8 @@ settings: base: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete - MARKETING_VERSION: "0.4.0" - CURRENT_PROJECT_VERSION: "3" + MARKETING_VERSION: "0.4.1" + CURRENT_PROJECT_VERSION: "4" # Ad-hoc by default so anyone can clone and build with no Apple account. # Override both in Local.xcconfig (gitignored) for a stable signature that # keeps the Keychain entry valid across rebuilds — see Local.xcconfig.example. @@ -67,7 +67,10 @@ targets: base: INFOPLIST_FILE: Resources/Info.plist PRODUCT_BUNDLE_IDENTIFIER: net.hlnd.Cleared - PRODUCT_NAME: Cleared + # The app is GitCleared. The target, the module and the bundle + # identifier keep the name it started with — see AppEnvironment.name. + PRODUCT_NAME: GitCleared + PRODUCT_MODULE_NAME: Cleared ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon COMBINE_HIDPI_IMAGES: YES LD_RUNPATH_SEARCH_PATHS: ["$(inherited)", "@executable_path/../Frameworks"] @@ -83,6 +86,10 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: net.hlnd.ClearedTests GENERATE_INFOPLIST_FILE: YES + # XcodeGen derives the test host from the target's name, which is no + # longer what the app it builds is called. + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/GitCleared.app/Contents/MacOS/GitCleared" + BUNDLE_LOADER: "$(TEST_HOST)" schemes: Cleared: