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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.swiftpm/
.DS_Store
*.app
*.dmg
*.dSYM
DerivedData/
*.xcarchive
Expand Down
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@ until 2026-07-28. `docs/naming.md` records the rename and what it deliberately
left alone.

- Run `./scripts/verify.sh` for the full local gate: tests, release build,
temporary app packaging/signing, the `UsageBar` → `Left` migration, bundle
and icon checks, fixture rendering against committed evidence, secret scan,
and diff checks.
temporary app and disk-image packaging/signing, the `UsageBar` → `Left`
migration, bundle and icon checks, fixture rendering against committed
evidence, secret scan, and diff checks.
- Run `./scripts/install.sh` to install and launch the menu-only app in
`~/Applications`; use `--destination` and `--no-launch` for a scoped install.
- Run `./scripts/package-dmg.sh` to write an ad-hoc-signed (or Developer ID)
`Left.dmg` under `.build/` for GitHub Releases. It prefers a universal
arm64+x86_64 binary and must not install to `~/Applications`. Dual
`swift build --arch arm64 --arch x86_64` needs Xcode's xcbuild; the
packager builds each slice and `lipo`s them so Command Line Tools can
still ship a fat binary. Probe `security find-identity -v -p codesigning`
before inventing notarization; this project ships ad-hoc unless a
Developer ID and stored `NOTARY_KEYCHAIN_PROFILE` are already on the
machine.
- Keep provider access behind `QuotaProviderAdapter` in
`Sources/LeftCore/QuotaAXIAdapter.swift`. Production invocation arguments
are fixed to `--provider claude,codex,grok --json` and must never include
Expand Down
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@ records why it is `Left`, and what the rename deliberately left alone.
Requirements:

- macOS 13 or newer
- Swift from Xcode or the Xcode command-line tools
- `quota-axi` 0.1.6 or newer on `PATH`

From this checkout, build, package, install, and launch the app with one
command:
### Download

[Left.dmg](https://github.com/ItsFlow/left/releases/latest/download/Left.dmg) —
Intel or Apple Silicon. First launch: right-click the app and choose Open.

### From this checkout

Also needs Swift from Xcode or the Xcode command-line tools. From this
checkout, build, package, install, and launch the app with one command:

```sh
./scripts/install.sh
Expand Down Expand Up @@ -98,11 +104,11 @@ compares both byte for byte, so re-render them whenever the surface changes.
```

Verification runs the parser, state, layout, contrast, and mark tests, builds
release mode, packages and signs a temporary app, exercises the `UsageBar` →
`Left` migration and its rollback copy, checks the bundle identifier and icon,
asserts no login-item machinery, re-renders the overlay at 340 × 430 and the
menu-bar readout and compares both to the committed evidence, scans for common
secret patterns, and checks the Git diff.
release mode, packages and signs a temporary app and disk image, exercises the
`UsageBar` → `Left` migration and its rollback copy, checks the bundle
identifier and icon, asserts no login-item machinery, re-renders the overlay at
340 × 430 and the menu-bar readout and compares both to the committed
evidence, scans for common secret patterns, and checks the Git diff.

## Smoke test after installing

Expand Down
10 changes: 10 additions & 0 deletions scripts/identity.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Shared identity for the app bundle. Sourced by the packaging scripts.
# shellcheck disable=SC2034

APP_NAME="Left"
BUNDLE_ID="local.firstmate.usagebar"
LEGACY_APP_NAME="UsageBar"
VERSION="0.2.0"
BUILD_NUMBER="2"
MINIMUM_SYSTEM_VERSION="13.0"
47 changes: 3 additions & 44 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ set -Eeuo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
APP_NAME="Left"
BUNDLE_ID="local.firstmate.usagebar"
LEGACY_APP_NAME="UsageBar"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/identity.sh"
DESTINATION="${HOME:?HOME is required}/Applications/${APP_NAME}.app"
# Rollback copies must not live beside the install. LaunchServices scans
# ~/Applications and registers every bundle it finds, so a backup kept there
Expand Down Expand Up @@ -120,14 +119,6 @@ if [[ "$DRY_RUN" == true ]]; then
fi

cd "$PROJECT_ROOT"
swift build -c release --product "$APP_NAME"
BIN_DIR="$(swift build -c release --show-bin-path)"
APP_BINARY="$BIN_DIR/$APP_NAME"
[[ -x "$APP_BINARY" ]] || {
echo "error: release binary was not produced" >&2
exit 1
}

mkdir -p "$PROJECT_ROOT/.build"
STAGING_ROOT="$(mktemp -d "$PROJECT_ROOT/.build/install.XXXXXX")"
[[ "$STAGING_ROOT" == "$PROJECT_ROOT"/.build/install.* ]] || {
Expand All @@ -140,39 +131,7 @@ cleanup() {
trap cleanup EXIT

STAGED_APP="$STAGING_ROOT/${APP_NAME}.app"
CONTENTS="$STAGED_APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
ditto "$APP_BINARY" "$CONTENTS/MacOS/$APP_NAME"
chmod 755 "$CONTENTS/MacOS/$APP_NAME"

# The mark is drawn by the app itself, so the icon can never drift from the
# menu-bar template or from what the tests assert.
ICONSET="$STAGING_ROOT/${APP_NAME}.iconset"
"$APP_BINARY" --render-iconset "$ICONSET"
iconutil --convert icns --output "$CONTENTS/Resources/AppIcon.icns" "$ICONSET"
[[ -s "$CONTENTS/Resources/AppIcon.icns" ]] || {
echo "error: app icon was not produced" >&2
exit 1
}

INFO_PLIST="$CONTENTS/Info.plist"
plutil -create xml1 "$INFO_PLIST"
plutil -insert CFBundleDevelopmentRegion -string en "$INFO_PLIST"
plutil -insert CFBundleExecutable -string "$APP_NAME" "$INFO_PLIST"
plutil -insert CFBundleIconFile -string AppIcon "$INFO_PLIST"
# Deliberately unchanged by the rename: macOS keys login-item registration,
# TCC decisions, and keychain ACLs to this string.
plutil -insert CFBundleIdentifier -string "$BUNDLE_ID" "$INFO_PLIST"
plutil -insert CFBundleInfoDictionaryVersion -string 6.0 "$INFO_PLIST"
plutil -insert CFBundleName -string "$APP_NAME" "$INFO_PLIST"
plutil -insert CFBundleDisplayName -string "$APP_NAME" "$INFO_PLIST"
plutil -insert CFBundlePackageType -string APPL "$INFO_PLIST"
plutil -insert CFBundleShortVersionString -string 0.2.0 "$INFO_PLIST"
plutil -insert CFBundleVersion -string 2 "$INFO_PLIST"
plutil -insert LSMinimumSystemVersion -string 13.0 "$INFO_PLIST"
plutil -insert LSUIElement -bool true "$INFO_PLIST"
codesign --force --sign - "$STAGED_APP"
codesign --verify --strict "$STAGED_APP"
"$SCRIPT_DIR/package-app.sh" --destination "$STAGED_APP"

# Stop whatever is running from either bundle before replacing it, so the
# install never leaves two status items polling the same allowance.
Expand Down
230 changes: 230 additions & 0 deletions scripts/package-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -Eeuo pipefail

# Assemble a signed Left.app at --destination. Does not install, launch,
# register with LaunchServices, or touch ~/Applications/Left.app.

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/identity.sh"

DESTINATION=""
UNIVERSAL=false

usage() {
echo "Usage: ./scripts/package-app.sh --destination /absolute/${APP_NAME}.app [--universal]"
}

while [[ $# -gt 0 ]]; do
case "$1" in
--destination)
[[ $# -ge 2 ]] || {
echo "error: --destination needs a path" >&2
exit 2
}
DESTINATION="$2"
shift 2
;;
--universal)
UNIVERSAL=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done

[[ "$(uname -s)" == "Darwin" ]] || {
echo "error: ${APP_NAME} requires macOS" >&2
exit 1
}
[[ -n "$DESTINATION" ]] || {
echo "error: --destination is required" >&2
usage >&2
exit 2
}
[[ "$DESTINATION" == /* && "$DESTINATION" == *.app ]] || {
echo "error: destination must be an absolute .app path" >&2
exit 2
}

LIVE_INSTALL="${HOME:?HOME is required}/Applications/${APP_NAME}.app"
DESTINATION_PARENT="$(dirname -- "$DESTINATION")"
[[ -d "$DESTINATION_PARENT" ]] || {
echo "error: destination parent must be an existing directory" >&2
exit 2
}
DESTINATION_PARENT="$(cd -- "$DESTINATION_PARENT" && pwd -P)"
DESTINATION="$DESTINATION_PARENT/$(basename -- "$DESTINATION")"
if [[ -d "$(dirname -- "$LIVE_INSTALL")" ]]; then
LIVE_INSTALL_PARENT="$(cd -- "$(dirname -- "$LIVE_INSTALL")" && pwd -P)"
else
LIVE_INSTALL_PARENT="$(cd -- "$HOME" && pwd -P)/Applications"
fi
LIVE_INSTALL="$LIVE_INSTALL_PARENT/$(basename -- "$LIVE_INSTALL")"
[[ "$DESTINATION" != "$LIVE_INSTALL" ]] || {
echo "error: refusing to write ${LIVE_INSTALL}; use ./scripts/install.sh" >&2
exit 2
}

command -v swift >/dev/null || {
echo "error: install the Xcode command-line tools for Swift" >&2
exit 1
}

resolve_codesign_identity() {
local line identity
while IFS= read -r line; do
case "$line" in
*'Developer ID Application'*)
identity="${line#*\"}"
identity="${identity%%\"*}"
if [[ -n "$identity" && "$identity" != "$line" ]]; then
printf '%s\n' "$identity"
return 0
fi
;;
esac
done < <(security find-identity -v -p codesigning 2>/dev/null || true)
printf '%s\n' "-"
}

sign_app() {
local app="$1"
local identity="$2"
if [[ "$identity" == "-" ]]; then
codesign --force --sign - "$app"
return 0
fi
codesign --force --options runtime --timestamp --sign "$identity" "$app"
# Notarytool prompts for an Apple ID unless a keychain profile is already
# named. Never invent one, and never submit without it.
if [[ -n "${NOTARY_KEYCHAIN_PROFILE:-}" ]]; then
echo "Notarizing with keychain profile ${NOTARY_KEYCHAIN_PROFILE}" >&2
ditto -c -k --keepParent "$app" "${app}.zip"
xcrun notarytool submit "${app}.zip" --wait \
--keychain-profile "$NOTARY_KEYCHAIN_PROFILE"
xcrun stapler staple "$app"
rm -f -- "${app}.zip"
else
echo "Developer ID is present but NOTARY_KEYCHAIN_PROFILE is unset;" \
"leaving ${APP_NAME}.app unnotarized" >&2
fi
}

cd "$PROJECT_ROOT"
mkdir -p "$PROJECT_ROOT/.build"

# Dual `--arch arm64 --arch x86_64` goes through Xcode's xcbuild, which Command
# Line Tools do not ship. Build each slice and lipo them instead.
build_slice() {
local arch="$1"
local bin_dir
swift build -c release --product "$APP_NAME" --arch "$arch" >&2 || return
bin_dir="$(swift build -c release --product "$APP_NAME" --arch "$arch" --show-bin-path)" || return
[[ -x "$bin_dir/$APP_NAME" ]] || {
echo "error: ${arch} build did not produce an executable" >&2
return 1
}
printf '%s\n' "$bin_dir/$APP_NAME"
}

APP_BINARY=""
if [[ "$UNIVERSAL" == true ]]; then
echo "Building universal (arm64 + x86_64) ${APP_NAME}" >&2
ARM_BINARY="$(build_slice arm64)" || {
echo "error: arm64 build failed" >&2
exit 1
}
X86_BINARY="$(build_slice x86_64)" || {
echo "error: x86_64 build failed" >&2
exit 1
}
[[ -x "$ARM_BINARY" && -x "$X86_BINARY" ]] || {
echo "error: universal build did not produce both architecture slices" >&2
exit 1
}
lipo -create "$ARM_BINARY" "$X86_BINARY" \
-output "$PROJECT_ROOT/.build/${APP_NAME}.universal"
APP_BINARY="$PROJECT_ROOT/.build/${APP_NAME}.universal"
fi
if [[ "$UNIVERSAL" != true ]]; then
swift build -c release --product "$APP_NAME"
APP_BINARY="$(swift build -c release --product "$APP_NAME" --show-bin-path)/$APP_NAME"
fi
[[ -x "$APP_BINARY" ]] || {
echo "error: release binary was not produced" >&2
exit 1
}

if [[ "$UNIVERSAL" == true ]]; then
ARCHS="$(lipo -archs "$APP_BINARY" 2>/dev/null || true)"
[[ "$ARCHS" == *arm64* && "$ARCHS" == *x86_64* ]] || {
echo "error: universal build did not produce arm64 + x86_64: ${ARCHS:-unknown}" >&2
exit 1
}
fi

if [[ -e "$DESTINATION" ]]; then
rm -rf -- "$DESTINATION"
fi
CONTENTS="$DESTINATION/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
ditto "$APP_BINARY" "$CONTENTS/MacOS/$APP_NAME"
chmod 755 "$CONTENTS/MacOS/$APP_NAME"

# The mark is drawn by the app itself, so the icon can never drift from the
# menu-bar template or from what the tests assert.
ICON_ROOT="$(mktemp -d "$PROJECT_ROOT/.build/iconset.XXXXXX")"
[[ "$ICON_ROOT" == "$PROJECT_ROOT"/.build/iconset.* ]] || {
echo "error: unsafe iconset path" >&2
exit 1
}
cleanup_iconset() {
rm -rf -- "$ICON_ROOT"
}
trap cleanup_iconset EXIT
ICONSET="$ICON_ROOT/${APP_NAME}.iconset"
"$APP_BINARY" --render-iconset "$ICONSET"
iconutil --convert icns --output "$CONTENTS/Resources/AppIcon.icns" "$ICONSET"
[[ -s "$CONTENTS/Resources/AppIcon.icns" ]] || {
echo "error: app icon was not produced" >&2
exit 1
}

INFO_PLIST="$CONTENTS/Info.plist"
plutil -create xml1 "$INFO_PLIST"
plutil -insert CFBundleDevelopmentRegion -string en "$INFO_PLIST"
plutil -insert CFBundleExecutable -string "$APP_NAME" "$INFO_PLIST"
plutil -insert CFBundleIconFile -string AppIcon "$INFO_PLIST"
# Deliberately unchanged by the rename: macOS keys login-item registration,
# TCC decisions, and keychain ACLs to this string.
plutil -insert CFBundleIdentifier -string "$BUNDLE_ID" "$INFO_PLIST"
plutil -insert CFBundleInfoDictionaryVersion -string 6.0 "$INFO_PLIST"
plutil -insert CFBundleName -string "$APP_NAME" "$INFO_PLIST"
plutil -insert CFBundleDisplayName -string "$APP_NAME" "$INFO_PLIST"
plutil -insert CFBundlePackageType -string APPL "$INFO_PLIST"
plutil -insert CFBundleShortVersionString -string "$VERSION" "$INFO_PLIST"
plutil -insert CFBundleVersion -string "$BUILD_NUMBER" "$INFO_PLIST"
plutil -insert LSMinimumSystemVersion -string "$MINIMUM_SYSTEM_VERSION" "$INFO_PLIST"
plutil -insert LSUIElement -bool true "$INFO_PLIST"

IDENTITY="$(resolve_codesign_identity)"
sign_app "$DESTINATION" "$IDENTITY"
codesign --verify --strict "$DESTINATION"

echo "Packed ${APP_NAME} ${VERSION} at: $DESTINATION"
if [[ "$IDENTITY" == "-" ]]; then
echo "Signature: ad-hoc"
else
echo "Signature: Developer ID (${IDENTITY})"
fi
echo "Architectures: $(lipo -archs "$CONTENTS/MacOS/$APP_NAME")"
Loading