Compare commits

..
Author SHA1 Message Date
Codeman maintainer 7edf1a1c27 feat(desktop): a blink style for each state of the agents
The logo said one thing: agents are working. It now has a style of its own
for each state the tabs show: at work, finished and waiting for you (yellow),
and stopped on a permission or a question (red), set under View > Blink Apple
Logo. The most urgent state with a style wins, red then yellow then work, and
a state set to Off falls through to the next instead of silencing the logo.

Red and yellow come from `GET /api/approvals`, the list the page rebuilds
those tabs from after a reload, read natively for the reason the session list
is: WebKit stalls a hidden page's stream. An idle item stops counting once it
is acknowledged, so the logo stops when the tab's colour goes. The request is
skipped while neither alert has a style, a 404 from an older server reads as
no alerts, and a request that fails keeps the last answer. A dialog on screen
costs the server one tmux capture per poll.

Alarm is new: three sharp flashes and a short rest. It stays under the limit
of three flashes in any second, so its urgency comes from its shape.

A style saved by the earlier version stays as the one for work, and yellow
and red start as Wink and Alarm, since whoever chose one asked for a logo that
talks. `SETTLE` goes from 2 s to 3 s, the time the server takes to judge a new
dialog, so a prompt answered before its hook landed never flashes red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:26:16 +01:00
Codeman maintainer cd4cee15a7 feat(desktop): blink the lid Apple logo while agents work
On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid
can blink while agents work and stop when they finish, so a run can be
read from across the room. View > Blink Apple Logo While Agents Work
picks a style (Calm, Slow, Wink, Heartbeat, Fast). It is off by default,
and the menu exists only on a Mac that has the logo.

Apple documents one control for the logo, `sudo defaults write
com.apple.CoreBrightness LEBacklightMode -int 0`. The same setting is a
property of BrightnessSystemClient in the private CoreBrightness
framework, which a process of the logged-in user can set with no
administrator, so nothing here needs a helper or a password.

The setting is system-wide and survives a reboot, and the release profile
has panic = "abort", so the work runs in a child process (this binary
with --logo-blink) tied to the app by a pipe. When the pipe closes,
however the app ended, the child restores the logo and exits. A marker
file covers power loss and a SIGKILL of the child itself.

The child asks api/sessions itself: WebKit stalls the event stream of a
hidden page, which lost whole turns and ran a blink 39 s past the end.
It sends the session cookie of its first answer with every poll, because
a good password without a cookie creates a login session each time and
Codeman evicts the oldest of 100.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 05:06:39 +01:00
16 changed files with 2056 additions and 851 deletions

No files matched your search

-72
View File
@@ -1,72 +0,0 @@
name: Desktop app
# Checks the native macOS app in packages/desktop on the pull requests and master
# pushes that change it: formatting, clippy and the unit tests, on macOS. No
# secret and a read-only token, because cargo runs the build script of every
# crate in Cargo.lock. The release build, signing and notarization live in
# desktop-dmg.yml.
on:
pull_request:
paths:
- 'packages/desktop/**'
- '.github/workflows/desktop-ci.yml'
push:
branches: [master]
paths:
- 'packages/desktop/**'
- '.github/workflows/desktop-ci.yml'
permissions:
contents: read
concurrency:
group: desktop-ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
# Keep in step with desktop-dmg.yml, which builds the release with it.
RUST_TOOLCHAIN: '1.99.1'
jobs:
check:
name: fmt, clippy, test
runs-on: macos-26
timeout-minutes: 45
defaults:
run:
working-directory: packages/desktop
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy --component rustfmt
rustup default "$RUST_TOOLCHAIN"
rustc --version
cargo --version
- name: Format
run: cargo fmt --check
# A test build bakes another installer in at compile time (option_env! in
# setup.rs), so neither variable may reach cargo.
- name: Clippy
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
cargo clippy --locked --all-targets -- -D warnings
- name: Unit tests
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
cargo test --locked
# /bin/bash on macOS is 3.2, the bash these scripts run under on a Mac.
- name: Scripts parse on bash 3.2
run: |
for script in scripts/*.sh; do
/bin/bash -n "$script"
done
-399
View File
@@ -1,399 +0,0 @@
name: macOS app
# Builds Codeman.app (Apple Silicon + Intel), signs it with the Developer ID,
# has Apple notarize the app and the DMG, and attaches Codeman.dmg and
# Codeman.dmg.sha256 to a Codeman release. The asset name never changes, so
# https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always
# serves the newest app.
#
# Release calls this when npm took a Codeman release, with tag codeman@<version>,
# and it builds the release commit. By hand, once this file is on master:
# gh workflow run desktop-dmg.yml # dry run of master: artifacts only
# gh workflow run desktop-dmg.yml --ref <branch> # dry run of a branch that carries this file
# gh workflow run desktop-dmg.yml -f tag=codeman@1.50.0 # rebuild that tag, replace its Codeman.dmg
# Only a run on master (or Release itself) attaches anything; every run leaves
# the signed Codeman.dmg as an artifact on its run page. A rebuild takes only a
# Codeman release tag whose commit is on master ("Check the tag", in build and
# in sign).
#
# A dispatch runs its ref's copy of this file with the secrets, as every
# repository secret reaches anyone with write access. A GitHub Environment
# limited to master would not narrow the rebuild path (the job's ref is master
# while it runs the tag's scripts) and would refuse the branch dry run above;
# the tag check is what keeps a rebuild on master's history.
#
# Three jobs, so no job that holds a secret runs code from a crate:
# - build runs cargo, and with it the build script of every crate in
# Cargo.lock. It has a read-only token and nothing else: no secret, no cache.
# - sign gets the built app as an artifact and runs only Apple's tools and this
# repository's scripts on it. It alone holds the signing and notary secrets.
# - attach has the one token that can write to a release, and no secret.
on:
workflow_call:
inputs:
tag:
description: Release to attach Codeman.dmg to (codeman@x.y.z); empty attaches nothing
type: string
required: false
default: ''
secrets:
MACOS_CERT_P12:
description: The Developer ID Application certificate with its private key, a base64 .p12
required: true
MACOS_CERT_PASSWORD:
description: The .p12's password
required: true
APPLE_ID:
description: Apple ID of the developer team, for the notary service
required: true
APPLE_APP_PASSWORD:
description: An app-specific password for that Apple ID
required: true
APPLE_TEAM_ID:
description: The team ID the certificate belongs to
required: true
workflow_dispatch:
inputs:
tag:
description: 'Release tag to rebuild and attach to, e.g. codeman@1.50.0. Empty: dry run, artifacts only'
type: string
required: false
default: ''
permissions:
contents: read
env:
# What the macos-26 image and the Mac mini that built every tested DMG both ran
# on 2026-11-08. Move it deliberately, together with desktop-ci.yml; Cargo.toml's
# rust-version is the floor.
RUST_TOOLCHAIN: '1.99.1'
# The commit to build: a rebuild by hand builds the tag it attaches to; Release
# (a push to master) and a dry run build the commit they ran on.
BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' && format('refs/tags/{0}', inputs.tag) || github.sha }}
jobs:
build:
name: Build Codeman.app
runs-on: macos-26
timeout-minutes: 60
permissions:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ env.BUILD_REF }}
persist-credentials: false
# A rebuild by hand builds the tag it names, and sign then runs that tag's
# scripts with the Developer ID key unlocked. So the tag must be a Codeman
# release (another package's tag would put Codeman.dmg on its release), and
# its commit must be on master's history, where those scripts were reviewed.
# aicodeman@ is the tag a failed rename leaves (Release's retag step).
# A tag can move between jobs, so build and sign each check their own
# checkout. Inline on purpose: a script from the checkout would be the
# tag's own code. Kept the same as the step in sign.
- name: Check the tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
if ! [[ $TAG =~ $TAG_RE ]]; then
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
exit 1
fi
SHA=$(git rev-parse HEAD)
# behind: an ancestor of master's tip; identical: the tip itself.
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
case "$STATUS" in
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
*)
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
exit 1
;;
esac
- name: Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \
--target aarch64-apple-darwin --target x86_64-apple-darwin
rustup default "$RUST_TOOLCHAIN"
rustc --version
cargo --version
# A release installs Codeman from getcodeman.com/install and master. A test
# build bakes another installer in at compile time (option_env! in setup.rs),
# so neither variable may reach cargo here, and setup.rs's tests pin the default.
- name: Unit tests
working-directory: packages/desktop
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
cargo test --locked
- name: Bundle Codeman.app (signed ad hoc)
working-directory: packages/desktop
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH SIGN_IDENTITY
./scripts/bundle-macos.sh --universal
- name: Check the build
working-directory: packages/desktop
run: |
BIN=dist/Codeman.app/Contents/MacOS/codeman-desktop
lipo "$BIN" -verify_arch arm64 x86_64
codesign --verify --strict dist/Codeman.app
strings -a "$BIN" > "$RUNNER_TEMP/strings.txt"
if grep -E 'raw\.githubusercontent\.com|test/desktop-' "$RUNNER_TEMP/strings.txt"; then
echo "::error::A test installer URL or branch is baked into the app"
exit 1
fi
grep -q 'https://getcodeman.com/install' "$RUNNER_TEMP/strings.txt"
# ditto keeps the bundle's modes, symlinks and extended attributes; an
# artifact of the bare folder would not (upload-artifact sets every file to
# 644). The artifact is a zip around this zip: download-artifact unpacks the
# outer one and leaves the ditto zip as it was.
- name: Archive Codeman.app
run: |
mkdir -p "$RUNNER_TEMP/unsigned"
ditto -c -k --sequesterRsrc --keepParent packages/desktop/dist/Codeman.app "$RUNNER_TEMP/unsigned/Codeman.app.zip"
- name: Upload the unsigned app
uses: actions/upload-artifact@v7
with:
name: codeman-app-unsigned
path: ${{ runner.temp }}/unsigned/Codeman.app.zip
if-no-files-found: error
retention-days: 7
overwrite: true
sign:
name: Sign and notarize
needs: build
runs-on: macos-26
# Two notary submissions; each usually takes minutes, and sign-macos.sh
# gives each at most 45.
timeout-minutes: 120
permissions:
contents: read
steps:
# For sign-macos.sh, make-dmg.sh and macos/entitlements.plist only. Nothing
# here builds or runs code from the app or its crates.
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ env.BUILD_REF }}
persist-credentials: false
# The same check as in build, on this job's own checkout, before anything
# here touches a secret. Kept the same as the step in build.
- name: Check the tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
if ! [[ $TAG =~ $TAG_RE ]]; then
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
exit 1
fi
SHA=$(git rev-parse HEAD)
# behind: an ancestor of master's tip; identical: the tip itself.
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
case "$STATUS" in
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
*)
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
exit 1
;;
esac
- name: Download the unsigned app
uses: actions/download-artifact@v8
with:
name: codeman-app-unsigned
path: ${{ runner.temp }}/unsigned
# The build job ran every crate's build script, so its zip is checked before
# this job, which holds the secrets, unpacks it: entries only under
# Codeman.app/ (and the __MACOSX/ folder where ditto keeps attributes), no
# "..", and no symlink, which the app never has and which could point an
# unpack or a later write outside the app.
- name: Unpack Codeman.app
run: |
ZIP="$RUNNER_TEMP/unsigned/Codeman.app.zip"
ENTRIES=$(zipinfo -1 "$ZIP")
LISTING=$(zipinfo "$ZIP")
if printf '%s\n' "$ENTRIES" | grep -vE '^(Codeman\.app|__MACOSX)/' \
|| printf '%s\n' "$ENTRIES" | grep -E '(^|/)\.\.(/|$)' \
|| printf '%s\n' "$LISTING" | grep '^l'; then
echo "::error::The build artifact holds an entry outside Codeman.app, a '..' or a symlink"
exit 1
fi
mkdir -p "$RUNNER_TEMP/app"
ditto -x -k "$ZIP" "$RUNNER_TEMP/app"
if [ -n "$(find "$RUNNER_TEMP/app" -type l)" ]; then
echo "::error::The unpacked app holds a symlink"
exit 1
fi
test -x "$RUNNER_TEMP/app/Codeman.app/Contents/MacOS/codeman-desktop"
# A keychain of its own under RUNNER_TEMP, with a random password, holds the
# Developer ID key for the length of this job. The last step deletes it and
# the decoded .p12, whatever happened before. All five secrets are checked
# here, so a missing one stops the job before any work.
- name: Signing keychain
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
for name in MACOS_CERT_P12 MACOS_CERT_PASSWORD APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "::error::The repository secret $name is empty or not set"
exit 1
fi
done
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
P12="$RUNNER_TEMP/codeman-signing.p12"
trap 'rm -f "$P12"' EXIT
KEYCHAIN_PASSWORD=$(openssl rand -hex 32)
echo "::add-mask::$KEYCHAIN_PASSWORD"
# Line breaks out first: an encoder that wraps its output (GNU base64
# without -w0) leaves some that macOS's base64 refuses.
printf '%s' "$MACOS_CERT_P12" | tr -d '\r\n ' | base64 --decode > "$P12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
# Locks after six hours, not after the default five idle minutes, which
# a notarization wait can outlast.
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$P12" -k "$KEYCHAIN" -f pkcs12 -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
rm -f "$P12"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
# First on the search list, ahead of the runner's own keychains.
SEARCH=()
while IFS= read -r line; do
line=${line#*\"}
line=${line%\"*}
if [ -n "$line" ]; then SEARCH+=("$line"); fi
done < <(security list-keychains -d user)
security list-keychains -d user -s "$KEYCHAIN" "${SEARCH[@]}"
# The identity by its SHA-1 hash, which no other certificate can match.
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN")
MATCHES=$(printf '%s\n' "$IDENTITIES" | grep '"Developer ID Application: ' || true)
COUNT=$(printf '%s' "$MATCHES" | grep -c . || true)
if [ "$COUNT" != 1 ]; then
printf '%s\n' "$IDENTITIES"
echo "::error::MACOS_CERT_P12 holds $COUNT valid Developer ID Application identities; it needs exactly one"
exit 1
fi
HASH=$(printf '%s\n' "$MATCHES" | awk '{ print $2 }')
IDENTITY=$(printf '%s\n' "$MATCHES" | sed 's/^[^"]*"\(.*\)"$/\1/')
case "$IDENTITY" in
*"($APPLE_TEAM_ID)") ;;
*)
echo "::error::The certificate in MACOS_CERT_P12 ($IDENTITY) is not one of team APPLE_TEAM_ID"
exit 1
;;
esac
echo "Signing as $IDENTITY ($HASH)"
echo "SIGN_IDENTITY=$HASH" >> "$GITHUB_ENV"
# The notary login goes to notarytool on its command line (sign-macos.sh's
# APPLE_ID path), in this job only, which already holds the signing key.
- name: Sign, notarize and staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packages/desktop/scripts/sign-macos.sh "$RUNNER_TEMP/app/Codeman.app" "$RUNNER_TEMP/out"
# A dry run's result: codeman-dmg on the run page (gh run download <run-id>
# -n codeman-dmg). attach takes it from here too.
- name: Upload Codeman.dmg
uses: actions/upload-artifact@v7
with:
name: codeman-dmg
path: |
${{ runner.temp }}/out/Codeman.dmg
${{ runner.temp }}/out/Codeman.dmg.sha256
if-no-files-found: error
retention-days: 30
overwrite: true
- name: Remove the signing keychain
if: always()
run: |
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
if [ -e "$KEYCHAIN" ]; then
security delete-keychain "$KEYCHAIN" || true
fi
rm -f "$KEYCHAIN" "$RUNNER_TEMP/codeman-signing.p12"
attach:
name: Attach to the release
needs: sign
# Release runs on pushes to master, so github.ref is master both there and for
# a rebuild started by hand on master. A dry run from any other branch, or a
# fork, attaches nothing.
if: inputs.tag != '' && github.ref == 'refs/heads/master' && github.repository == 'Ark0N/Codeman'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Download Codeman.dmg
uses: actions/download-artifact@v8
with:
name: codeman-dmg
path: out
- name: Upload to the release
id: upload
working-directory: out
env:
GH_TOKEN: ${{ github.token }}
REQUESTED_TAG: ${{ inputs.tag }}
run: |
sha256sum -c Codeman.dmg.sha256
TAG=$REQUESTED_TAG
# Release's retag step ignores its own errors, so a release whose rename to
# codeman@ failed still carries the tag changesets gave it.
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
case "$TAG" in
codeman@*) TAG="aicodeman@${TAG#codeman@}" ;;
esac
gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null
echo "::warning::Attaching to $TAG: there is no release $REQUESTED_TAG"
fi
gh release upload "$TAG" Codeman.dmg Codeman.dmg.sha256 --clobber -R "$GITHUB_REPOSITORY"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Check the download links
working-directory: out
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.upload.outputs.tag }}
run: |
EXPECTED=$(cut -d' ' -f1 Codeman.dmg.sha256)
check() {
curl -fsSL --retry 6 --retry-delay 10 --retry-all-errors -o "$RUNNER_TEMP/served.dmg" "$1"
echo "$EXPECTED $RUNNER_TEMP/served.dmg" | sha256sum -c --quiet -
echo "$1 serves this build"
}
check "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/Codeman.dmg"
LATEST=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
if [ "$LATEST" = "$TAG" ]; then
check "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/Codeman.dmg"
else
echo "::warning::Latest is $LATEST, so releases/latest/download/Codeman.dmg does not serve $TAG"
fi
+2 -90
View File
@@ -14,11 +14,6 @@ jobs:
permissions:
contents: write
pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
# Set only when aicodeman itself was published: `published` is also true for
# a release that moved only xterm-zerolag-input.
codeman_version: ${{ steps.codeman.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
@@ -65,98 +60,15 @@ jobs:
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
# releases already exist by the time this step runs, so setting it here
# is deterministic.
#
# The title and body are the page people land on from the update toast.
# changesets copied the body from CHANGELOG.md as one long bullet, so it is
# reshaped here (scripts/lib/release-notes.mjs). npm has already published by
# now, so a failure in that must not stop the retag below: without the body
# field the release keeps the text changesets wrote.
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
BODY_ARGS=()
if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then
BODY_ARGS=(-F "body=@$NOTES_FILE")
else
echo "::warning::Keeping the changesets release notes for $VERSION"
fi
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-f tag_name="$NEW_TAG" \
-f name="Codeman ${VERSION}" \
-f make_latest=true \
"${BODY_ARGS[@]}"
-f name="$NEW_TAG" \
-f make_latest=true
fi
# Retag
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
git tag -d "$OLD_TAG" 2>/dev/null || true
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
# The macOS app job below runs only for a Codeman release, so it reads the
# version from the list of packages this run published. Never fails the job:
# npm has published by now, and a bad list only means no app build.
- name: Codeman version published by this run
id: codeman
if: steps.changesets.outputs.published == 'true'
env:
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
run: |
VERSION=$(printf '%s' "$PUBLISHED_PACKAGES" | jq -r '.[] | select(.name == "aicodeman") | .version' || true)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Codeman version published: ${VERSION:-none (no macOS app build)}"
# releases/latest/download/Codeman.dmg (the Mac app's stable link) serves
# whichever release holds Latest. GitHub gives Latest to the release created
# last, and a publish that moved only xterm-zerolag-input creates one the
# retag above never touches, so Latest goes back to the newest Codeman
# release after every publish. A failure warns and leaves npm, the tag and
# the MCP publish alone.
- name: Keep Latest on the Codeman release
if: steps.changesets.outputs.published == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -p "require('./package.json').version")
RELEASE_ID=$(gh release view "codeman@${VERSION}" --json databaseId -q .databaseId 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
echo "::warning::No codeman@${VERSION} release to mark Latest"
elif ! gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" -f make_latest=true > /dev/null; then
echo "::warning::Could not mark codeman@${VERSION} Latest"
fi
# Its own job: this one alone holds id-token, and a registry failure shows as itself
# without touching the npm publish or the tag above.
mcp-registry:
name: MCP registry
needs: release
if: needs.release.outputs.published == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/publish-mcp.yml
# Builds, signs and notarizes the Mac app and attaches Codeman.dmg to the
# release (.github/workflows/desktop-dmg.yml), only when Codeman itself was
# published. Its own job, so a failed DMG shows as itself and never touches
# npm, the tag or the MCP publish; `gh workflow run desktop-dmg.yml -f
# tag=codeman@<version>` reruns it. It gets the five secrets it uses and no
# other: never `secrets: inherit`, which would hand it NPM_TOKEN.
macos-app:
name: macOS app
needs: release
if: needs.release.outputs.codeman_version != ''
# The ceiling for the called jobs; only its attach job asks for write. A job in
# desktop-dmg.yml that asks for a permission not granted here (id-token or
# attestations for a provenance attestation, say) makes GitHub refuse this
# whole Release run at startup, npm publish included: widen both together.
permissions:
contents: write
uses: ./.github/workflows/desktop-dmg.yml
with:
tag: codeman@${{ needs.release.outputs.codeman_version }}
secrets:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+2 -53
View File
@@ -19,70 +19,19 @@ When user says "COM":
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
**Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman <x.y.z>`). The in-app update toast reads the same bold lead-ins:
```markdown
### Thanks
- @someone for what they did (#123).
**What the change does for the user (#123).** Two or three plain sentences.
**Fixes.**
- **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently.
**Fixes applied while landing.** One sentence each.
**Docs and tests.** One sentence.
```
`### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@<x.y.z> --notes-file <file>`.
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
A site change that links the Mac app (`Codeman.dmg`, `/mac`) deploys only after step 7 showed the `macOS app` job green and the [DMG check](#the-mac-app-codemandmg) answered 200: the DMG reaches the release well after the npm publish, and the download link answers 404 until then.
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release, the MCP registry publish and, when Codeman itself was published, the `macOS app` job that attaches `Codeman.dmg`). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them.
8. **Publish the new version to the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so.
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
## The Mac app (Codeman.dmg)
Every Codeman release carries the native macOS app (`packages/desktop`) as `Codeman.dmg` plus `Codeman.dmg.sha256`. The name never changes, so https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always serves the newest app, while each release keeps its own copy at `https://github.com/Ark0N/Codeman/releases/download/codeman@<x.y.z>/Codeman.dmg` (link that one in release notes). The app keeps its own version (`packages/desktop/Cargo.toml`), so the DMG of Codeman 1.50.0 holds app 0.1.0.
**How it gets there.** When npm took a Codeman release (not one that moved only xterm-zerolag-input), the `Release` run's `macOS app` job calls `.github/workflows/desktop-dmg.yml` with the tag `codeman@<x.y.z>`. It has three jobs, so no job that holds a secret runs code from a crate: `build` runs `cargo test --locked` and `bundle-macos.sh --universal` on the release commit with a read-only token and no secret; `sign` imports the Developer ID certificate into a throwaway keychain and runs `packages/desktop/scripts/sign-macos.sh`, which signs the app with the hardened runtime, has Apple notarize it, staples the ticket, builds the DMG around it and signs, notarizes and staples that too; `attach` uploads both files to the release (`--clobber`) and checks that the download links serve those bytes. The release job's `Keep Latest on the Codeman release` step puts the Latest badge back on the newest `codeman@` release after every publish, since the stable link follows that badge.
**The secrets** are repository secrets, passed to the job by name (never `secrets: inherit`, which would hand it `NPM_TOKEN`): `MACOS_CERT_P12` (the Developer ID Application certificate WITH its private key, exported from Keychain Access as a .p12, then `base64 -i <file>.p12`), `MACOS_CERT_PASSWORD` (the export password), `APPLE_ID`, `APPLE_APP_PASSWORD` (an app-specific password for that Apple ID, made at account.apple.com) and `APPLE_TEAM_ID`. A Developer ID certificate lasts five years; its successor replaces the first two secrets and nothing else.
**Check it before anything links to it.** The DMG lands after a universal build and two notary submissions, so well after the npm publish, and until then the stable link answers 404:
```bash
gh release view codeman@<x.y.z> --json assets -q '.assets[].name' # Codeman.dmg and Codeman.dmg.sha256
curl -sIL -o /dev/null -w '%{http_code}\n' https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg # 200
```
**Dry run.** `gh workflow run desktop-dmg.yml` builds, signs and notarizes master and attaches nothing; `gh workflow run desktop-dmg.yml --ref <branch>` does the same for a branch that carries the workflow (GitHub starts a manual run only for a workflow file that is also on master). The signed DMG is the run's `codeman-dmg` artifact (`gh run download <run-id> -n codeman-dmg`). For a first-launch test, download it on a Mac through the browser from the run page instead, so the copy is quarantined like one from a release.
**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@<x.y.z>`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. The tag must be a Codeman release tag (`codeman@x.y.z`, or the `aicodeman@x.y.z` a failed rename leaves) whose commit is on master's history: `build` and `sign` both refuse anything else before they run its scripts, since `sign` runs them with the signing key unlocked. A failed job is also retried in place with `gh run rerun <run-id> --failed`.
**If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log <id>` with the same login shows it again).
- `HTTP status code: 401` (invalid credentials) or `403` (a required agreement is missing or has expired) at the first submission: a new Apple ID password revokes every app-specific password, so make a new one and update `APPLE_APP_PASSWORD`, or accept the new agreement at developer.apple.com, then `gh run rerun <run-id> --failed`.
- `0 valid Developer ID Application identities` in the `Signing keychain` step: the .p12 lacks its private key or holds another certificate type. A team mismatch there means `APPLE_TEAM_ID` names another team than the certificate.
- `In Progress` at the 45-minute limit, or a network error: Apple's service was slow. Rerun the failed job.
- `Invalid`: Apple's log names the file and the reason (a binary without the hardened runtime or a secure timestamp, an entitlement it refuses). That is a code fix and a new release. Until then keep the stable link on the last app that passed, `gh release edit codeman@<previous> --latest`, and hold any site change that links the new one.
**By hand on a Mac**, only when the workflow cannot be fixed in time: in a clean checkout of the tag, `env -u CODEMAN_DESKTOP_INSTALLER_URL -u CODEMAN_DESKTOP_INSTALL_BRANCH packages/desktop/scripts/bundle-macos.sh --universal`, then `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app <out-dir>` (a profile saved with `xcrun notarytool store-credentials`), then `gh release upload codeman@<x.y.z> <out-dir>/Codeman.dmg <out-dir>/Codeman.dmg.sha256 --clobber`. Never upload a build made with either of those two variables set: it installs Codeman from the test branch it names.
**Toolchain and runner.** `desktop-dmg.yml` and the pull-request check `desktop-ci.yml` (`cargo fmt --check`, clippy with `-D warnings` and `cargo test --locked` on macOS, for changes under `packages/desktop`) both pin `RUST_TOOLCHAIN` and run on `macos-26`. Move them together.
## Security notices
A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it.
+1
View File
@@ -164,6 +164,7 @@ dependencies = [
"base64 0.22.1",
"block2",
"dirs 6.0.0",
"libc",
"muda",
"objc2",
"objc2-app-kit",
+6
View File
@@ -30,6 +30,9 @@ ureq = { version = "3", features = ["platform-verifier"] }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree
# through wry and tao, so no new crate is added.
libc = "0.2"
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
# content size (src/macos.rs). The versions wry already pulls in, so no new
# crate is added.
@@ -39,7 +42,9 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"std",
"objc2-core-foundation",
"block2",
"NSBundle",
"NSDate",
"NSDictionary",
"NSGeometry",
"NSObject",
"NSOperation",
@@ -53,6 +58,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSURLRequest",
"NSURLResponse",
"NSURLSession",
"NSValue",
] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
"std",
+68 -9
View File
@@ -13,8 +13,8 @@ Windows and Linux later.
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
No bundled browser and no Node in the app.
for what wry leaves out on macOS (JavaScript dialogs, HTTP Basic login) and for
the lid logo. No bundled browser and no Node in the app.
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
## Why it is cheap
@@ -40,6 +40,7 @@ cannot do.
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
## Build (on a Mac)
@@ -187,8 +188,68 @@ instead of being answered. A refusal that lands while you are in another app
does not bring Codeman forward: the connect screen loads where the window is,
and the Dock icon bounces.
**The logo blinks from a child process, through CoreBrightness.** Apple documents
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
for every blink would need a root helper and an administrator password. The same
setting is also a property of `BrightnessSystemClient` in the private
CoreBrightness framework, and a process of the logged-in user can set it there,
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
keeps its value while the logo is dark.
The setting is system-wide and survives a reboot, so the app must not end with the
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
back and exits. Switching it off closes the same pipe, so the path a crash
takes is the one used every day. A marker file
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
at the next launch the app puts back a logo a blink left dark. The framework is
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
answers nil) is what hides the option, so a macOS that drops it loses this feature
and nothing else.
**Red and yellow come from the server's approvals list.** `GET /api/approvals` is what the
page reads to rebuild its red and yellow tabs after a reload, and it follows the same rules:
`permission` and `question` items are red, an `idle` item is yellow until someone has looked
at it (`acknowledgedAt`). So the logo stops when the tab's colour goes. The request is skipped
when neither alert has a style, and a server from before the list reads as no alerts. Each
poll with a dialog on screen costs that server one tmux capture, because the list re-checks
the dialog, which also clears red soon after a dialog is answered in the terminal.
**The agent count is asked for natively, because the page cannot be trusted with it.**
The first version read the page's own event stream, which needs no second connection
and no login. On a real Mac it failed in both directions as soon as the window was
covered, because WebKit stalls the stream of a hidden page: a turn that started while
it was stalled was never seen and the logo stayed still, and in another run the logo
kept blinking 39 seconds after the agent finished, until the page's watchdog reopened
the stream. A hidden window is the normal case for a signal meant to be read from
across the room, so `src/agents.rs` asks `api/sessions` itself, from the child process,
every two seconds. That needs care with the login. A request with a good password and
no session cookie makes Codeman create a login session, it keeps 100 of them, and the
oldest goes first, so polling with the password alone would log every browser out
within minutes. The poller therefore sends the cookie of the first answer with every
request, which Codeman reads first, so one session is created per server start. A
refused login ends the polling for good, because Codeman counts each refusal against
the login rate limit. The app and the child share nothing but the pipe the login
crosses, so the login is never on a command line.
## Known limits
- **The logo blink rests on a private macOS interface.** Apple documents the
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a blink
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
@@ -213,12 +274,10 @@ and the Dock icon bounces.
Anyway* in System Settings > Privacy & Security (it asks for an administrator
password). On macOS 14 and earlier, right-click it and choose *Open* once.
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
copy in Applications works too. The `Codeman.dmg` on each GitHub release is
signed with a Developer ID and notarized by CI
(`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the
same by hand:
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
where `scripts/sign-macos.sh` lists what it needs.
copy in Applications works too. Shipping it to other people needs a Developer
ID signature and notarization:
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
then `xcrun notarytool submit` and `xcrun stapler staple`.
- **Local Network permission (macOS 15 and later).** The first connection to an
address on the local network (a LAN IP, a `.local` name) makes macOS ask
whether Codeman may use the local network, and that first attempt can fail
@@ -280,4 +339,4 @@ and the Dock icon bounces.
- Trusting a self-signed server certificate by its fingerprint.
- Asking before a download that a rendered HTML file or a web tab starts.
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
- Windows and Linux builds, auto-update.
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
+7 -25
View File
@@ -4,8 +4,6 @@
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> \
# packages/desktop/scripts/bundle-macos.sh --universal --notarize
#
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
@@ -13,15 +11,6 @@
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
# in /Applications. A Developer ID signature + notarization avoids all of this.
#
# --dmg writes dist/Codeman-<version>.dmg, signed with SIGN_IDENTITY when it is
# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it
# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does
# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied).
# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE
# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow
# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and
# sign-macos.sh in another, so the job that runs cargo holds no secret.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -29,21 +18,14 @@ cd "$(dirname "$0")/.."
UNIVERSAL=0
DMG=0
NOTARIZE=0
for arg in "$@"; do
case "$arg" in
--universal) UNIVERSAL=1 ;;
--dmg) DMG=1 ;;
--notarize) NOTARIZE=1 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
if [ "$NOTARIZE" = 1 ]; then
# Checked before the build, so a missing secret costs seconds, not a build.
./scripts/sign-macos.sh --check
fi
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
@@ -116,13 +98,13 @@ codesign --verify --strict "$APP"
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
if [ "$NOTARIZE" = 1 ]; then
./scripts/sign-macos.sh "$APP" dist
elif [ "$DMG" = 1 ]; then
if [ "$DMG" = 1 ]; then
STAGE="$WORK/dmg"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
DMG_PATH="dist/Codeman-$VERSION.dmg"
./scripts/make-dmg.sh "$APP" "$DMG_PATH"
if [ -n "${SIGN_IDENTITY:-}" ]; then
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
fi
rm -f "$DMG_PATH"
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
fi
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Put Codeman.app in a compressed disk image next to a link to /Applications,
# so opening the image shows the app and the folder to drag it into.
#
# packages/desktop/scripts/make-dmg.sh <Codeman.app> <out.dmg>
#
# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG
# and the release DMG are laid out the same way. It signs nothing.
set -euo pipefail
[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; }
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out.dmg>" >&2; exit 2; }
APP=$1
DMG=$2
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
STAGE="$WORK/dmg"
mkdir -p "$STAGE"
# ditto keeps what cp -R can lose: extended attributes, and with them a
# stapled notarization ticket.
ditto "$APP" "$STAGE/$(basename "$APP")"
ln -s /Applications "$STAGE/Applications"
# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the
# same command a few seconds later passes.
rm -f "$DMG"
tries=1
until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do
if [ "$tries" -ge 3 ]; then
echo "hdiutil create failed $tries times" >&2
exit 1
fi
tries=$((tries + 1))
echo "hdiutil create failed, trying again in 10 seconds" >&2
rm -f "$DMG"
sleep 10
done
hdiutil verify -quiet "$DMG"
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env bash
# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the
# ticket to it, then build Codeman.dmg around the stapled app and do the same
# for the DMG. On macOS.
#
# packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>
# packages/desktop/scripts/sign-macos.sh --check # only check the environment below
#
# <Codeman.app> is re-signed and stapled in place. <out-dir> receives
# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it,
# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both
# appear only once every check passed; a failed run leaves neither, not even an
# older pair, so nothing half-done can be uploaded by mistake.
#
# The environment names who signs:
# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain
# on the search list, by name or by the SHA-1 hash that
# `security find-identity -v -p codesigning` prints
# and how to reach Apple's notary service, either through a profile saved with
# `xcrun notarytool store-credentials <profile> --apple-id ... --team-id ...`:
# NOTARY_PROFILE the profile's name
# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login)
# or with the Apple ID itself:
# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID
#
# By hand, on a Mac with the certificate in the login keychain:
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \
# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist
#
# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that
# builds nothing: the job that runs cargo, and with it the build script of every
# crate in Cargo.lock, never holds a signing secret.
set -euo pipefail
[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; }
DESKTOP=$(cd "$(dirname "$0")/.." && pwd)
ENTITLEMENTS="$DESKTOP/macos/entitlements.plist"
# Fails with the name of what is missing, before any work, so a missing secret
# costs seconds rather than a build.
check_env() {
if [ -z "${SIGN_IDENTITY:-}" ]; then
echo "sign-macos.sh needs SIGN_IDENTITY" >&2
exit 2
fi
if [ -n "${NOTARY_PROFILE:-}" ]; then
return 0
fi
local name
for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then
echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2
exit 2
fi
done
}
if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then
check_env
exit 0
fi
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out-dir> | --check" >&2; exit 2; }
check_env
APP=$1
OUT=$2
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256"
NAME=$(basename "$APP" .app)
if [ -n "${NOTARY_PROFILE:-}" ]; then
NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE")
if [ -n "${NOTARY_KEYCHAIN:-}" ]; then
NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN")
fi
else
# On notarytool's command line, which the job's log never shows.
NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID")
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Submit a file to the notary service and wait. Anything but Accepted stops the
# script with the submission id and Apple's log, which names the file and the
# reason (an unsigned binary, a missing timestamp, no hardened runtime...).
notarize() {
local file=$1 result="$WORK/notary.plist" rc=0 status id
echo "Submitting $(basename "$file") to Apple's notary service"
xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \
--output-format plist > "$result" || rc=$?
status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true)
id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true)
if [ "$status" != Accepted ]; then
echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2
cat "$result" >&2 || true
if [ -n "$id" ]; then
xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true
fi
exit 1
fi
echo "Notarized $(basename "$file") (submission $id)"
}
# The app: hardened runtime (which notarization requires), a secure timestamp,
# and the entitlements the hardened runtime needs for microphone and camera.
codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" "$APP"
codesign --verify --strict --verbose=2 "$APP"
DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1)
printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true
case "$DETAILS" in
*"Authority=Developer ID Application: "*) ;;
*) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;;
esac
case "$DETAILS" in
*"(runtime)"*) ;;
*) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;;
esac
ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip"
notarize "$WORK/$NAME.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl --assess --type execute --verbose=2 "$APP"
# The DMG around the stapled app, signed and notarized in its own right, so the
# image opens without a warning and the app inside opens offline.
DMG="$WORK/Codeman.dmg"
"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG"
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG"
(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256)
mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/"
echo "Signed and notarized $APP"
echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")"
+462
View File
@@ -0,0 +1,462 @@
//! What the agents on a server are doing, asked from outside the web view: at
//! work, finished with nobody having looked yet (a yellow tab), or stopped on a
//! permission or a question (a red tab).
//!
//! The page knows this too, but it cannot be relied on for it: WebKit stalls the
//! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a
//! `session:idle` the server sent never reached it, its own watchdog reopened the
//! stream 39 seconds later, and in another run a whole turn went unseen. A hidden
//! window is the normal case for this question, so it is asked natively, of
//! `api/sessions`, a stable route that works with any Codeman, and of
//! `api/approvals`, the list the page itself builds its red and yellow tabs from
//! when it loads. A server from before that list has no such route, which reads
//! as no alerts.
//!
//! A login has a price here that a single request does not show. A request with
//! a good password and no session cookie makes Codeman create a login session,
//! it keeps 100 of them, and the oldest goes first, so asking every two seconds
//! with the password alone would log every browser out within minutes. So the
//! cookie of the first answer goes along from then on, and Codeman reads the
//! cookie first: the password is only used when the cookie has lapsed (a server
//! restart), and then it creates the one new session that replaces it. Sending
//! both also means a stale cookie costs no failed login. A refused login ends
//! the polling for good, since Codeman counts each one against the login rate
//! limit (see `probe`).
use serde::Deserialize;
use std::time::Duration;
use url::Url;
/// How long the logo can lag behind the server. The list is cached for a
/// second on the server's side and is a few kilobytes.
pub const POLL_EVERY: Duration = Duration::from_secs(2);
const TIMEOUT: Duration = Duration::from_secs(5);
const SESSIONS_PATH: &str = "api/sessions";
const APPROVALS_PATH: &str = "api/approvals";
const COOKIE_NAME: &str = "codeman_session";
/// What the agents on a server are doing. Any number of these can be true.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Activity {
/// At least one agent is at work.
pub working: bool,
/// An agent finished and nobody has looked at it yet: a yellow tab.
pub waiting: bool,
/// An agent stopped on a permission or a question: a red tab.
pub needs_you: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Poll {
Seen(Activity),
/// No answer this time. Worth asking again.
Failed,
/// The server turned the login down. Never ask again.
Refused,
}
#[derive(Deserialize)]
struct Session {
#[serde(default)]
status: String,
#[serde(default)]
mode: String,
}
/// Codeman wraps the list in its API envelope, and older servers sent it bare.
#[derive(Deserialize)]
#[serde(untagged)]
enum Sessions {
Envelope { data: Vec<Session> },
Bare(Vec<Session>),
}
/// Agents only: a shell tab with a `tail -f` is a run of output, which the
/// server may call busy, and that is nobody's agent.
fn count_working(body: &str) -> Option<u32> {
let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = serde_json::from_str(body).ok()?;
let working = sessions.iter().filter(|s| s.status == "busy" && s.mode != "shell");
u32::try_from(working.count()).ok()
}
#[derive(Deserialize)]
struct Approval {
#[serde(default)]
kind: String,
#[serde(default, rename = "acknowledgedAt")]
acknowledged_at: Option<f64>,
}
#[derive(Deserialize)]
struct Approvals {
approvals: Vec<Approval>,
}
#[derive(Deserialize)]
struct ApprovalsAnswer {
data: Approvals,
}
/// The two alerts, by the page's own rules: `permission_prompt` and a question
/// make a tab red, an idle prompt makes it yellow until someone has looked at it,
/// and an acknowledged item stays listed but alerts nobody.
fn read_alerts(body: &str) -> Option<(bool, bool)> {
let answer: ApprovalsAnswer = serde_json::from_str(body).ok()?;
let items = &answer.data.approvals;
let waiting = items.iter().any(|i| i.kind == "idle" && i.acknowledged_at.is_none());
let needs_you = items.iter().any(|i| i.kind == "permission" || i.kind == "question");
Some((waiting, needs_you))
}
/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie.
fn session_cookie(header: &str) -> Option<&str> {
let pair = header.split(';').next()?.trim();
let (name, value) = pair.split_once('=')?;
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
}
/// What a status code says about asking again.
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
match status {
200 => body()
.as_deref()
.and_then(count_working)
.map_or(Poll::Failed, |working| {
Poll::Seen(Activity {
working: working > 0,
..Activity::default()
})
}),
401 | 403 | 429 => Poll::Refused,
_ => Poll::Failed,
}
}
pub struct Poller {
agent: ureq::Agent,
sessions_url: String,
/// None when no alert has a style: that request is then never made.
approvals_url: Option<String>,
authorization: Option<String>,
cookie: Option<String>,
/// The last alerts seen, kept through a request that failed.
alerts: (bool, bool),
}
impl Poller {
pub fn new(server: &Url, authorization: Option<String>, alerts: bool) -> Option<Self> {
let approvals_url = match alerts {
true => Some(server.join(APPROVALS_PATH).ok()?.to_string()),
false => None,
};
Some(Poller {
agent: crate::probe::agent(TIMEOUT),
sessions_url: server.join(SESSIONS_PATH).ok()?.to_string(),
approvals_url,
authorization,
cookie: None,
alerts: (false, false),
})
}
/// One GET with the login, and the status with the body of a 200.
fn ask(&mut self, url: &str) -> Option<(u16, Option<String>)> {
let mut request = self.agent.get(url).header("Accept", "application/json");
if let Some(authorization) = &self.authorization {
request = request.header("Authorization", authorization);
}
if let Some(cookie) = &self.cookie {
request = request.header("Cookie", cookie);
}
let mut response = request.call().ok()?;
let fresh = response
.headers()
.get_all("set-cookie")
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(session_cookie)
.map(str::to_owned);
if fresh.is_some() {
self.cookie = fresh;
}
let status = response.status().as_u16();
let body = (status == 200).then(|| {
let body = response.body_mut().with_config();
body.limit(crate::probe::MAX_BODY).read_to_string().ok()
});
Some((status, body.flatten()))
}
pub fn poll(&mut self) -> Poll {
let url = self.sessions_url.clone();
let Some((status, body)) = self.ask(&url) else {
return Poll::Failed;
};
let mut activity = match answer(status, || body) {
Poll::Seen(activity) => activity,
other => return other,
};
if let Some(url) = self.approvals_url.clone() {
match self.ask(&url) {
Some((200, body)) => {
if let Some(alerts) = body.as_deref().and_then(read_alerts) {
self.alerts = alerts;
}
}
Some((401 | 403 | 429, _)) => return Poll::Refused,
// A server from before the list has no alerts to report.
Some((404, _)) => self.alerts = (false, false),
_ => {}
}
}
(activity.waiting, activity.needs_you) = self.alerts;
Poll::Seen(activity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
/// The start of a real answer of `GET /api/sessions`, captured from a
/// server: other fields around the two that matter, in Codeman's envelope.
const REAL_ANSWER: &str = r#"{"success":true,"data":[{"id":"0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5",
"pid":61017,"status":"busy","workingDir":"/Users/lee/codeman-cases/testcase",
"paneCwd":"/Users/lee/codeman-cases/testcase","currentTaskId":null,"mode":"opencode"}]}"#;
#[test]
fn reads_the_answer_a_real_server_gives() {
assert_eq!(count_working(REAL_ANSWER), Some(1));
assert_eq!(count_working(r#"{"success":true,"data":[]}"#), Some(0));
}
#[test]
fn counts_agents_at_work_and_nothing_else() {
let body = r#"{"success":true,"data":[
{"id":"a","status":"busy","mode":"claude"},
{"id":"b","status":"busy","mode":"codex"},
{"id":"c","status":"idle","mode":"claude"},
{"id":"d","status":"busy","mode":"shell"},
{"id":"e","status":"stopped","mode":"claude"},
{"id":"f","status":"error","mode":"opencode"}
]}"#;
assert_eq!(count_working(body), Some(2));
}
#[test]
fn reads_the_bare_list_of_an_older_server() {
assert_eq!(count_working(r#"[{"status":"busy","mode":"claude"}]"#), Some(1));
assert_eq!(count_working("[]"), Some(0));
}
#[test]
fn a_session_without_a_mode_counts_and_one_without_a_status_does_not() {
assert_eq!(
count_working(r#"{"data":[{"status":"busy"},{"mode":"claude"}]}"#),
Some(1)
);
}
#[test]
fn a_body_that_is_not_the_list_is_no_answer() {
assert_eq!(count_working("<html>"), None);
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
assert_eq!(answer(200, || None), Poll::Failed);
}
#[test]
fn only_a_login_refusal_ends_the_polling() {
for status in [401, 403, 429] {
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
}
for status in [301, 404, 500, 502, 503] {
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
}
}
#[test]
fn finds_codemans_cookie_in_a_set_cookie_header() {
assert_eq!(
session_cookie("codeman_session=abc123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax"),
Some("codeman_session=abc123")
);
assert_eq!(session_cookie("codeman_session=abc123"), Some("codeman_session=abc123"));
assert_eq!(session_cookie("other=1; Path=/"), None);
assert_eq!(session_cookie("codeman_session=; Max-Age=0"), None);
assert_eq!(session_cookie(""), None);
}
/// A server that answers each connection with the next canned response
/// and records the headers it was sent.
fn serve(responses: Vec<String>) -> (u16, Arc<Mutex<Vec<Vec<String>>>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
std::thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut headers = Vec::new();
let mut line = String::new();
while reader.read_line(&mut line).is_ok() && line.trim() != "" {
headers.push(line.trim().to_ascii_lowercase());
line.clear();
}
log.lock().unwrap().push(headers);
stream.write_all(response.as_bytes()).unwrap();
}
});
(port, seen)
}
const EMPTY: &str = r#"{"success":true,"data":[]}"#;
fn plain(working: bool) -> Poll {
Poll::Seen(Activity {
working,
..Activity::default()
})
}
/// The shape of a real `GET /api/approvals`, with the frame text cut.
fn approvals(items: &[&str]) -> String {
format!(r#"{{"success":true,"data":{{"approvals":[{}]}}}}"#, items.join(","))
}
const IDLE: &str = r#"{"id":"s:1","sessionId":"s","sessionName":"w4","kind":"idle","createdAt":1,"context":"x"}"#;
const LOOKED_AT: &str = r#"{"id":"s:2","sessionId":"s","kind":"idle","createdAt":1,"acknowledgedAt":2}"#;
const PERMISSION: &str = r#"{"id":"s:3","sessionId":"s","kind":"permission","createdAt":1,"toolName":"Bash"}"#;
const QUESTION: &str = r#"{"id":"s:4","sessionId":"s","kind":"question","createdAt":1}"#;
#[test]
fn the_alerts_follow_the_pages_rules() {
assert_eq!(read_alerts(&approvals(&[])), Some((false, false)));
assert_eq!(read_alerts(&approvals(&[IDLE])), Some((true, false)));
assert_eq!(read_alerts(&approvals(&[LOOKED_AT])), Some((false, false)), "seen");
assert_eq!(read_alerts(&approvals(&[PERMISSION])), Some((false, true)));
assert_eq!(read_alerts(&approvals(&[QUESTION])), Some((false, true)));
assert_eq!(read_alerts(&approvals(&[IDLE, PERMISSION])), Some((true, true)));
assert_eq!(read_alerts("not json"), None);
assert_eq!(read_alerts(EMPTY), None);
}
#[test]
fn reads_all_three_states_from_one_poll() {
let busy = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, seen_requests) = serve(vec![ok("", busy), ok("", &approvals(&[IDLE, PERMISSION]))]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, None, true).unwrap();
let everything = Activity {
working: true,
waiting: true,
needs_you: true,
};
assert_eq!(poller.poll(), Poll::Seen(everything));
assert!(seen_requests.lock().unwrap()[1][0].starts_with("get /api/approvals "));
}
fn status(code: u16) -> String {
format!("HTTP/1.1 {code} X\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
}
#[test]
fn a_server_without_the_list_still_shows_work() {
let busy = r#"{"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, _) = serve(vec![ok("", busy), status(404)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), plain(true));
}
#[test]
fn a_refused_list_ends_the_polling_and_a_failed_one_keeps_the_last_answer() {
let (port, _) = serve(vec![ok("", EMPTY), status(401)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), Poll::Refused);
let (port, _) = serve(vec![
ok("", EMPTY),
ok("", &approvals(&[PERMISSION])),
ok("", EMPTY),
status(500),
]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, None, true).unwrap();
let red = Poll::Seen(Activity {
needs_you: true,
..Activity::default()
});
assert_eq!(poller.poll(), red);
assert_eq!(
poller.poll(),
Poll::Seen(Activity {
needs_you: true,
..Activity::default()
})
);
}
#[test]
fn no_alert_with_a_style_means_no_second_request() {
let (port, seen_requests) = serve(vec![ok("", EMPTY)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
assert_eq!(seen_requests.lock().unwrap().len(), 1);
}
fn ok(extra_headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}",
body.len()
)
}
#[test]
fn keeps_one_login_session_across_polls() {
let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, seen) = serve(vec![
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
ok("Set-Cookie: codeman_session=second; Max-Age=86400; Path=/\r\n", EMPTY),
ok("", EMPTY),
]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, Some("Basic abc".into()), false).unwrap();
assert_eq!(poller.poll(), plain(true));
assert_eq!(poller.poll(), plain(true));
assert_eq!(poller.poll(), plain(false));
assert_eq!(poller.poll(), plain(false));
let seen = seen.lock().unwrap();
let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned();
assert_eq!(cookie(0), None);
assert_eq!(cookie(1).as_deref(), Some("cookie: codeman_session=first"));
assert_eq!(cookie(2).as_deref(), Some("cookie: codeman_session=first"));
// A server restart hands out a new one; a response without one keeps it.
assert_eq!(cookie(3).as_deref(), Some("cookie: codeman_session=second"));
assert!(seen.iter().all(|h| h.contains(&"authorization: basic abc".to_string())));
assert!(seen[0][0].starts_with("get /api/sessions "), "{:?}", seen[0][0]);
}
#[test]
fn asks_under_the_servers_base_path() {
let (port, seen) = serve(vec![ok("", EMPTY)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
let seen = seen.lock().unwrap();
assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]);
assert!(!seen[0].iter().any(|h| h.starts_with("authorization:")));
}
#[test]
fn a_server_that_answers_nothing_is_only_a_failed_poll() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed);
}
}
+9 -5
View File
@@ -184,17 +184,21 @@ pub const HOST_SHIM: &str = r#"(function () {
}
})();"#;
/// A toast inside Codeman when a download finished (Codeman's own
/// `showToast`; nothing happens on a page without it).
/// A toast inside Codeman (its own `showToast`; nothing happens on a page
/// without it). `kind` is one of Codeman's fixed toast kinds.
pub fn toast_script(text: &str, kind: &str) -> String {
let text = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, \"{kind}\"); }} catch (e) {{}}")
}
/// A toast inside Codeman when a download finished.
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
let text = if ok {
format!("Downloaded {file_name} to Downloads")
} else {
format!("Download of {file_name} failed")
};
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
let kind = if ok { "\"success\"" } else { "\"error\"" };
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
toast_script(&text, if ok { "success" } else { "error" })
}
/// Runs in the window that asked for a window the native side did not open
+37
View File
@@ -3,6 +3,7 @@
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
//! never go here; they live in the Keychain (see `credentials`).
use crate::logo::BlinkStyles;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
@@ -43,6 +44,11 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// What the lit Apple logo does for each state of the agents (logo.rs). The
/// menu only shows on a Mac that has one. A value this version does not know
/// reads as off: a bad style must not make the whole file unreadable.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Anything that is not a whole frame reads as None: a bad frame must not
/// make the whole file unreadable, which would cost the server list.
#[serde(deserialize_with = "lenient_window")]
@@ -54,12 +60,17 @@ fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Windo
Ok(serde_json::from_value(value).ok())
}
fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles, D::Error> {
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
}
impl Default for Config {
fn default() -> Self {
Config {
servers: Vec::new(),
auto_connect: true,
start_fullscreen: false,
logo_blink: BlinkStyles::default(),
window: None,
}
}
@@ -399,6 +410,32 @@ mod tests {
assert!(can_save);
}
#[test]
fn the_logo_blink_styles_round_trip_and_a_bad_one_reads_as_off() {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
c.logo_blink.needs_you = BlinkStyle::Alarm;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
r#"{"logoBlink":null}"#,
r#"{"logoBlink":"strobe"}"#,
] {
let c: Config = serde_json::from_str(bad).unwrap();
assert_eq!(c.logo_blink, BlinkStyles::default(), "{bad}");
}
}
#[test]
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
use crate::logo::BlinkStyle;
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
}
#[test]
fn round_trip_and_atomic_save() {
let dir = tmp_dir("roundtrip");
File diff suppressed because it is too large. Load diff
+76 -2
View File
@@ -6,8 +6,9 @@
//! installs Codeman on this machine (`setup`, `local_server`) and installs it
//! on another one over SSH, real full screen, native menus, pop-outs as app
//! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads,
//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP
//! Basic challenges wry leaves out (`macos`).
//! passwords in the Keychain, on macOS the JavaScript dialogs and HTTP Basic
//! challenges wry leaves out (`macos`), and on a MacBook Pro with a lit Apple
//! logo a blink that says what the agents are doing (`logo`).
//!
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
//! only wires those decisions to windows. The main window moves through
@@ -19,10 +20,12 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod agents;
mod assets;
mod config;
mod credentials;
mod local_server;
mod logo;
#[cfg(target_os = "macos")]
mod macos;
mod policy;
@@ -51,6 +54,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView
use config::{Config, Rect, WindowFrame};
use credentials::Keychain;
use logo::{BlinkStyle, Signal};
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via};
use probe::ProbeOutcome;
@@ -96,6 +100,7 @@ const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The s
enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes.";
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
mod menu_id {
@@ -234,6 +239,9 @@ struct AppMenu {
window_menu: Submenu,
help_menu: Submenu,
start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
}
enum Load<'a> {
@@ -289,9 +297,16 @@ struct App {
/// While the main window may still have to be put into the full screen it
/// asked for at launch: when to look next, and how many retries were made.
fullscreen_retry: Option<(Instant, u8)>,
/// The child that watches the connected server and blinks the logo.
logo_watch: logo::Watcher,
}
fn main() {
// The blinker is this binary without a window (logo.rs).
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
logo::blink_main();
}
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
@@ -320,6 +335,7 @@ fn main() {
};
let config_path = config_path.filter(|_| can_save);
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
logo::recover_at_launch();
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
let phase = Rc::new(RefCell::new(Phase::Connect));
// Started once: the probe workers and the event loop share this queue,
@@ -371,6 +387,7 @@ fn main() {
menu,
keychain,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
logo_watch: logo::Watcher::default(),
};
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
@@ -459,6 +476,26 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has this menu at all.
let mut logo_styles = Vec::new();
if logo::available() {
let submenu = Submenu::new("Blink Apple Logo", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
let chosen = style == config.logo_blink.get(signal);
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
group.append(&item)?;
if style == BlinkStyle::Off {
group.append(&PredefinedMenuItem::separator())?;
}
logo_styles.push((signal, style, item));
}
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&submenu, 4)?;
}
let window_menu = Submenu::with_items(
"Window",
true,
@@ -485,6 +522,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_styles,
})
}
@@ -925,6 +963,7 @@ impl App {
if let Some(next) = next {
*self.phase.borrow_mut() = next;
}
self.sync_logo_watch();
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
@@ -1102,7 +1141,42 @@ impl App {
}
}
/// The logo is watched while any style is chosen and a server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.any() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
// A click flips the item it landed on, the chosen one included, so all
// of that state's are set.
for (item_signal, item_style, item) in &self.menu.logo_styles {
if *item_signal == signal {
item.set_checked(*item_style == style);
}
}
let was_off = !self.config.logo_blink.any();
self.config.logo_blink.set(signal, style);
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a style running, a dark logo may be our own.
if was_off && style != BlinkStyle::Off && logo::switched_off() {
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main
.webview
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
}
}
}
fn on_menu(&mut self, id: &str) {
if let Some((signal, style)) = BlinkStyle::from_menu_id(id) {
self.choose_logo_blink(signal, style);
return;
}
match id {
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::RELOAD => self.reload_focused(),
+17 -10
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use url::Url;
const TIMEOUT: Duration = Duration::from_secs(5);
const MAX_BODY: u64 = 8 * 1024 * 1024;
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
/// The path the probe asks for, relative to the server's base URL.
const STATUS_PATH: &str = "api/status";
@@ -231,16 +231,13 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
probe_within(server, authorization, TIMEOUT)
}
/// `probe` with the time the whole exchange may take.
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
/// The app's one way of asking a server something: the OS's certificate trust,
/// a bound on the whole exchange, and status codes as answers.
pub fn agent(timeout: Duration) -> ureq::Agent {
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.http_status_as_error(false)
// Never follow: a 3xx comes back as an answer and is explained.
@@ -248,8 +245,18 @@ fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) ->
.tls_config(tls)
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
.build()
.into();
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
.into()
}
/// `probe` with the time the whole exchange may take.
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
let mut req = agent(timeout)
.get(status_url.as_str())
.header("Accept", "application/json");
if let Some(auth) = authorization {
req = req.header("Authorization", auth);
}