mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea23445ad7 | ||
|
|
d5273347c3 | ||
|
|
94912241bc | ||
|
|
1e262b04d1 |
No files matched your search
@@ -0,0 +1,72 @@
|
||||
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
|
||||
@@ -0,0 +1,399 @@
|
||||
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
|
||||
@@ -14,6 +14,11 @@ 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
|
||||
@@ -60,15 +65,98 @@ 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="$NEW_TAG" \
|
||||
-f make_latest=true
|
||||
-f name="Codeman ${VERSION}" \
|
||||
-f make_latest=true \
|
||||
"${BODY_ARGS[@]}"
|
||||
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 }}
|
||||
+53
-2
@@ -19,19 +19,70 @@ 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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Generated
-1
@@ -164,7 +164,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"dirs 6.0.0",
|
||||
"libc",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
|
||||
@@ -30,9 +30,6 @@ 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.
|
||||
@@ -42,9 +39,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"block2",
|
||||
"NSBundle",
|
||||
"NSDate",
|
||||
"NSDictionary",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
@@ -58,7 +53,6 @@ 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",
|
||||
|
||||
@@ -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 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.
|
||||
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
|
||||
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,7 +40,6 @@ 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)
|
||||
@@ -188,68 +187,8 @@ 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
|
||||
@@ -274,10 +213,12 @@ crosses, so the login is never on a command line.
|
||||
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. 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`.
|
||||
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.
|
||||
- **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
|
||||
@@ -339,4 +280,4 @@ crosses, so the login is never on a command line.
|
||||
- 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, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
- Windows and Linux builds, auto-update.
|
||||
@@ -4,6 +4,8 @@
|
||||
# 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
|
||||
@@ -11,6 +13,15 @@
|
||||
# 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")/.."
|
||||
@@ -18,14 +29,21 @@ 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; }
|
||||
|
||||
@@ -98,13 +116,13 @@ codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$DMG" = 1 ]; then
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
./scripts/sign-macos.sh "$APP" dist
|
||||
elif [ "$DMG" = 1 ]; then
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
|
||||
./scripts/make-dmg.sh "$APP" "$DMG_PATH"
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
|
||||
fi
|
||||
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
|
||||
fi
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/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"
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/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")"
|
||||
@@ -1,462 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -184,21 +184,17 @@ pub const HOST_SHIM: &str = r#"(function () {
|
||||
}
|
||||
})();"#;
|
||||
|
||||
/// 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.
|
||||
/// A toast inside Codeman when a download finished (Codeman's own
|
||||
/// `showToast`; nothing happens on a page without it).
|
||||
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")
|
||||
};
|
||||
toast_script(&text, if ok { "success" } else { "error" })
|
||||
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) {{}}")
|
||||
}
|
||||
|
||||
/// Runs in the window that asked for a window the native side did not open
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! (`~/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;
|
||||
@@ -44,11 +43,6 @@ 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")]
|
||||
@@ -60,17 +54,12 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -410,32 +399,6 @@ 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
@@ -6,9 +6,8 @@
|
||||
//! 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, 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`).
|
||||
//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP
|
||||
//! Basic challenges wry leaves out (`macos`).
|
||||
//!
|
||||
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
|
||||
//! only wires those decisions to windows. The main window moves through
|
||||
@@ -20,12 +19,10 @@
|
||||
|
||||
#![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;
|
||||
@@ -54,7 +51,6 @@ 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;
|
||||
|
||||
@@ -100,7 +96,6 @@ 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 {
|
||||
@@ -239,9 +234,6 @@ 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> {
|
||||
@@ -297,16 +289,9 @@ 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();
|
||||
|
||||
@@ -335,7 +320,6 @@ 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,
|
||||
@@ -387,7 +371,6 @@ 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));
|
||||
@@ -476,26 +459,6 @@ 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,
|
||||
@@ -522,7 +485,6 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
window_menu,
|
||||
help_menu,
|
||||
start_fullscreen,
|
||||
logo_styles,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -963,7 +925,6 @@ 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();
|
||||
@@ -1141,42 +1102,7 @@ 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(),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
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,13 +231,16 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
probe_within(server, authorization, TIMEOUT)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// `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 tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
ureq::Agent::config_builder()
|
||||
let agent: ureq::Agent = 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.
|
||||
@@ -245,18 +248,8 @@ pub fn agent(timeout: Duration) -> ureq::Agent {
|
||||
.tls_config(tls)
|
||||
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.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");
|
||||
.into();
|
||||
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
|
||||
if let Some(auth) = authorization {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user