mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea23445ad7 | ||
|
|
d5273347c3 | ||
|
|
94912241bc | ||
|
|
1e262b04d1 | ||
|
|
92797c2619 | ||
|
|
a42ce9c79f | ||
|
|
116db3ba66 | ||
|
|
01d3379b74 |
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 }}
|
||||
@@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
'test/terminal-touch-swipe-axis.browser.test.ts',
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+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.
|
||||
|
||||
+28
-17
@@ -28,17 +28,18 @@ cannot do.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
|
||||
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
|
||||
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
|
||||
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
|
||||
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. |
|
||||
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. |
|
||||
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
|
||||
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
|
||||
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
|
||||
| **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. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). |
|
||||
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. Each pop-out opens a step down and to the right of the window in use, never exactly on top of another one. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
|
||||
| **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. |
|
||||
| **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)
|
||||
@@ -54,6 +55,12 @@ cargo test # unit tests
|
||||
open dist/Codeman.app
|
||||
```
|
||||
|
||||
The connect page has its own test, which needs no Mac: from the repository
|
||||
root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives
|
||||
`assets/connect.html` in Chromium through every state of both tabs, under the
|
||||
Content-Security-Policy the app serves it with. It is part of the nightly
|
||||
browser suite, not of `npm test`.
|
||||
|
||||
The script builds with `--locked` against the committed `Cargo.lock`, targets
|
||||
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
|
||||
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
|
||||
@@ -95,8 +102,8 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
- An app window opens only for Codeman's own pop-out pages under the server's
|
||||
base path, checked after decoding the path once, and which ones depends on
|
||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, and
|
||||
`api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, the
|
||||
path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
on from any frame without saying which, gets only the file routes and
|
||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||
HTML file could open copies of itself as top-level windows that pass for the
|
||||
@@ -206,10 +213,12 @@ and the Dock icon bounces.
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. 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
|
||||
@@ -234,11 +243,13 @@ and the Dock icon bounces.
|
||||
or later installed. On an older WebKit the connect screen says so and does
|
||||
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
|
||||
which the same requirement covers.
|
||||
- **Links in rendered HTML and web tabs do not open.** A link to another site
|
||||
inside a rendered HTML preview or a web tab does nothing, in the preview
|
||||
overlay, and for a rendered HTML file in its pop-out window too. The app
|
||||
sends links out only from Codeman's own pages, and blocks navigation to
|
||||
other sites in every frame (see Decisions).
|
||||
- **Plain links in rendered HTML and web tabs do not open.** A link to another
|
||||
site inside a rendered HTML preview or a web tab does nothing, in the
|
||||
preview overlay, and for a rendered HTML file in its pop-out window too: the
|
||||
app blocks navigation to other sites in every frame (see Decisions). A link
|
||||
there that asks for a new window (`target="_blank"`, or a script's
|
||||
`window.open`) does open, in your default browser, within the budget of 3 at
|
||||
once and then 1 per second.
|
||||
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
|
||||
only Codeman's own login, and WebKit shows no dialog for any other, so a
|
||||
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
|
||||
@@ -269,4 +280,4 @@ and the Dock icon bounces.
|
||||
- Trusting a self-signed server certificate by its fingerprint.
|
||||
- Asking before a download that a rendered HTML file or a web tab starts.
|
||||
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
|
||||
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
- Windows and Linux builds, auto-update.
|
||||
@@ -20,20 +20,35 @@
|
||||
--ok: #4ade80;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
html { height: 100%; }
|
||||
body {
|
||||
min-height: 100%; margin: 0;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
main { width: min(440px, calc(100vw - 48px)); }
|
||||
/* The top margin centres the tallest everyday page (a saved server with its
|
||||
login, 708px), so the header stays where it is while the card changes: a
|
||||
taller card grows downward, and a window too small for the page scrolls it
|
||||
from the top (centring with align-items would cut the top off, out of
|
||||
reach). */
|
||||
main { width: min(440px, calc(100vw - 48px)); margin: max(20px, calc((100vh - 708px) / 2)) 0 24px; }
|
||||
header { text-align: center; margin-bottom: 26px; }
|
||||
header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; }
|
||||
h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
|
||||
header p { margin: 4px 0 0; color: var(--muted); }
|
||||
@media (max-height: 640px) {
|
||||
header { margin-bottom: 16px; }
|
||||
header img { width: 48px; height: 48px; margin-bottom: 8px; }
|
||||
header p { display: none; }
|
||||
h1 { font-size: 20px; }
|
||||
}
|
||||
/* As tall as its usual content at least, with the two settings at its foot,
|
||||
so switching tabs or a status arriving does not resize it. */
|
||||
.card {
|
||||
display: flex; flex-direction: column; min-height: 304px;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
}
|
||||
@@ -67,16 +82,23 @@
|
||||
button.primary, button.secondary {
|
||||
height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default;
|
||||
}
|
||||
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); }
|
||||
button.primary:disabled { opacity: 0.55; }
|
||||
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, #2f6fed 0%, #2563eb 100%); }
|
||||
button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); }
|
||||
/* A press answers at once, before the app does. Disabled comes last, so a
|
||||
button that cannot be pressed does not look as if it could. */
|
||||
button.primary:hover { filter: brightness(1.07); }
|
||||
button.primary:active { filter: brightness(0.9); }
|
||||
button.secondary:hover { border-color: var(--accent-2); }
|
||||
button.secondary:disabled { opacity: 0.55; }
|
||||
button.secondary:active { background: #1f232b; }
|
||||
button.primary:disabled, button.secondary:disabled { opacity: 0.55; filter: none; }
|
||||
button.secondary:disabled { border-color: var(--line-strong); background: #16181d; }
|
||||
button:focus-visible, .check input:focus-visible, summary:focus-visible, footer a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
|
||||
button[hidden] { display: none; }
|
||||
#status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
|
||||
#status { margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
|
||||
#status.bad { color: var(--bad); }
|
||||
#status.warn { color: var(--warn); }
|
||||
#status.ok { color: var(--ok); }
|
||||
#status-text { min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; }
|
||||
#status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; }
|
||||
#suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#suggest[hidden] { display: none; }
|
||||
@@ -86,7 +108,8 @@
|
||||
.spinner[hidden] { display: none; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }
|
||||
.prefs { margin-top: 16px; }
|
||||
.prefs { margin-top: auto; }
|
||||
.prefs::before { content: ''; display: block; margin-top: 16px; padding-top: 4px; border-top: 1px solid var(--line); }
|
||||
.tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; }
|
||||
.tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
@@ -103,7 +126,8 @@
|
||||
#local-title { font-weight: 600; }
|
||||
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
|
||||
#local-network-row[hidden] { display: none; }
|
||||
#elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#elsewhere[hidden] { display: none; }
|
||||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||||
#elsewhere summary::-webkit-details-marker { display: none; }
|
||||
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
|
||||
@@ -116,10 +140,11 @@
|
||||
.oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; }
|
||||
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.statusrow[hidden] { display: none; }
|
||||
.statusrow #status { flex: 1; }
|
||||
.statusrow #cancel { margin-top: 10px; }
|
||||
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
|
||||
footer a { color: #7c8491; text-decoration: none; cursor: default; }
|
||||
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; }
|
||||
</style>
|
||||
@@ -143,7 +168,7 @@
|
||||
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
|
||||
<div class="local">
|
||||
<span class="dot busy" id="local-dot"></span>
|
||||
<div>
|
||||
<div role="status">
|
||||
<div id="local-title">Looking for Codeman on this Mac…</div>
|
||||
<div class="sub" id="local-detail"></div>
|
||||
</div>
|
||||
@@ -163,7 +188,7 @@
|
||||
<input id="url" type="text" spellcheck="false" autocapitalize="off"
|
||||
placeholder="http://127.0.0.1:3000 or https://your-box.ts.net" list="recent-list">
|
||||
<datalist id="recent-list"></datalist>
|
||||
<div id="recent" aria-label="Recent servers"></div>
|
||||
<div id="recent" role="group" aria-label="Recent servers"></div>
|
||||
|
||||
<div id="login" hidden>
|
||||
<div class="row">
|
||||
@@ -184,26 +209,28 @@
|
||||
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
|
||||
</div>
|
||||
|
||||
<details id="elsewhere">
|
||||
<summary>Codeman is not installed there yet?</summary>
|
||||
<label class="field" for="ssh">Install it over SSH</label>
|
||||
<div class="inline">
|
||||
<input id="ssh" type="text" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
|
||||
<button class="secondary" id="ssh-install" type="button">Install</button>
|
||||
</div>
|
||||
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
|
||||
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<div class="statusrow">
|
||||
<div class="statusrow" id="status-row" hidden>
|
||||
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
|
||||
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
|
||||
</div>
|
||||
<button class="secondary" id="suggest" type="button" hidden></button>
|
||||
|
||||
<details id="elsewhere" hidden>
|
||||
<summary>Codeman is not installed there yet?</summary>
|
||||
<label class="field" for="ssh">Install it over SSH</label>
|
||||
<div class="inline">
|
||||
<!-- 300 is what setup.rs takes for a login, and at most 900 bytes: under the cap of a message the app accepts. -->
|
||||
<input id="ssh" type="text" maxlength="300" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
|
||||
<button class="secondary" id="ssh-install" type="button">Install</button>
|
||||
</div>
|
||||
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
|
||||
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
|
||||
</details>
|
||||
|
||||
<div class="prefs">
|
||||
<label class="check"><input id="auto" type="checkbox"> Connect automatically when the app opens</label>
|
||||
<label class="check"><input id="auto" type="checkbox" checked> Connect automatically when the app opens</label>
|
||||
<label class="check"><input id="fullscreen" type="checkbox"> Always open in full screen <kbd>⌃⌘F</kbd></label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +259,7 @@
|
||||
form: $('form'), url: $('url'), list: $('recent-list'), recent: $('recent'),
|
||||
login: $('login'), username: $('username'), password: $('password'), remember: $('remember'),
|
||||
connect: $('connect'), cancel: $('cancel'), start: $('start'), suggest: $('suggest'),
|
||||
status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
|
||||
statusRow: $('status-row'), status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
|
||||
auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'),
|
||||
tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'),
|
||||
localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'),
|
||||
@@ -245,23 +272,49 @@
|
||||
// startUrl: the address the user asked to start Codeman for ('' when this
|
||||
// page did not ask, e.g. it was reloaded while a start was running).
|
||||
// suggestion: the address the suggest button connects to.
|
||||
// tab: 'local' (This Mac) or 'remote' (Another machine). local: the last
|
||||
// This Mac status from the app. wait: set while an install runs in
|
||||
// Terminal and this page polls for the server it brings up.
|
||||
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
|
||||
// the connect under way was asked from, where its answer belongs. local: the
|
||||
// last This Mac status from the app. wait: set while an install runs in
|
||||
// Terminal and this page polls for the server it brings up. reopen: a
|
||||
// connect to this Mac was refused, so open it if it turns out to be starting.
|
||||
// startingLooks: how often the page has looked again at a starting server.
|
||||
var state = {
|
||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
|
||||
stoppedWaiting: false
|
||||
tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false,
|
||||
wait: null, waitTimer: 0, stoppedWaiting: false,
|
||||
reopen: false, startingLooks: 0, startingTimer: 0
|
||||
};
|
||||
// While an install runs: how often to look, how long a finished install may
|
||||
// take to answer before Start is offered, and when to stop looking.
|
||||
var WAIT_POLL_MS = 3000;
|
||||
var WAIT_START_GRACE_MS = 90 * 1000;
|
||||
var WAIT_MAX_MS = 30 * 60 * 1000;
|
||||
// A server that is still starting: how often to look again, and how many times.
|
||||
var STARTING_POLL_MS = 1000;
|
||||
var STARTING_MAX_LOOKS = 20;
|
||||
// What the app takes in one message, in UTF-8 bytes (MAX_URL_LEN and
|
||||
// MAX_FIELD_LEN in policy.rs). It drops a longer one without an answer, so
|
||||
// the page has to be the one that says so.
|
||||
var MAX_URL = 4096;
|
||||
var MAX_FIELD = 1024;
|
||||
function tooLong(text, max) {
|
||||
var bytes = 0;
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var c = text.charCodeAt(i);
|
||||
if (c < 0x80) bytes += 1;
|
||||
else if (c < 0x800) bytes += 2;
|
||||
else if (c >= 0xd800 && c < 0xdc00) { bytes += 4; i++; } // a surrogate pair: one character
|
||||
else bytes += 3;
|
||||
}
|
||||
return bytes > max;
|
||||
}
|
||||
|
||||
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
|
||||
|
||||
function setStatus(kind, text, detail) {
|
||||
els.statusRow.hidden = !text;
|
||||
// In a small window the line may be below the fold: bring it into view.
|
||||
if (text && els.statusRow.scrollIntoView) els.statusRow.scrollIntoView({ block: 'nearest' });
|
||||
els.status.className = kind || '';
|
||||
els.spinner.hidden = kind !== 'busy';
|
||||
els.statusText.textContent = text || '';
|
||||
@@ -315,14 +368,16 @@
|
||||
}
|
||||
|
||||
// The address as the app normalizes it (policy::normalize_server_url):
|
||||
// http:// by default, https:// for a .ts.net name, no query or fragment, a
|
||||
// trailing slash. Only used to tell whether two addresses are one server.
|
||||
// http:// by default, https:// for a .ts.net name without a port, no query
|
||||
// or fragment, a trailing slash. Only used to tell whether two addresses
|
||||
// are one server.
|
||||
function normalize(raw) {
|
||||
var s = String(raw || '').trim();
|
||||
if (!s) return '';
|
||||
if (s.indexOf('://') < 0) {
|
||||
var name = s.split(/[\/:]/)[0].toLowerCase();
|
||||
s = (/\.ts\.net$/.test(name) ? 'https' : 'http') + '://' + s;
|
||||
var tailscaleServe = /\.ts\.net$/.test(name) && s.charAt(name.length) !== ':';
|
||||
s = (tailscaleServe ? 'https' : 'http') + '://' + s;
|
||||
}
|
||||
try {
|
||||
var u = new URL(s);
|
||||
@@ -362,6 +417,7 @@
|
||||
x.className = 'x';
|
||||
x.textContent = '×';
|
||||
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
||||
x.setAttribute('aria-label', 'Forget ' + host(s.url));
|
||||
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
||||
chip.appendChild(pick);
|
||||
chip.appendChild(x);
|
||||
@@ -382,20 +438,27 @@
|
||||
function matchSaved() {
|
||||
var match = state.servers.filter(function (s) { return s.url === els.url.value.trim(); })[0];
|
||||
if (match) choose(match);
|
||||
else els.password.placeholder = '';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (state.busy || tooOld) return;
|
||||
var url = els.url.value.trim();
|
||||
if (!url) { setStatus('bad', 'Enter the address of your Codeman server.'); els.url.focus(); return; }
|
||||
var username = els.login.hidden ? '' : els.username.value.trim();
|
||||
var password = els.login.hidden ? '' : els.password.value;
|
||||
if (tooLong(url, MAX_URL)) { setStatus('bad', 'That address is too long.'); els.url.focus(); return; }
|
||||
if (tooLong(username, MAX_FIELD) || tooLong(password, MAX_FIELD)) {
|
||||
setStatus('bad', 'That username or password is too long.');
|
||||
return;
|
||||
}
|
||||
state.lastUrl = url;
|
||||
state.askedFrom = state.tab;
|
||||
setBusy(true);
|
||||
setStatus('busy', 'Connecting to ' + url + '…');
|
||||
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
|
||||
if (!els.login.hidden) {
|
||||
if (els.username.value.trim()) msg.username = els.username.value.trim();
|
||||
if (els.password.value) msg.password = els.password.value;
|
||||
}
|
||||
if (username) msg.username = username;
|
||||
if (password) msg.password = password;
|
||||
post(msg);
|
||||
}
|
||||
|
||||
@@ -421,8 +484,7 @@
|
||||
unreachable: ['bad', 'This Mac cannot reach that address.', 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK],
|
||||
unresolvable: ['bad', 'That host name could not be found.', 'Check the spelling, or that Tailscale is connected for a .ts.net name.'],
|
||||
maybeHttps: ['bad', 'The server closed the connection without answering.', 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.'],
|
||||
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.'],
|
||||
passwordChangeRequired: ['warn', 'Codeman asks for a new password for this account.', 'Open it in a browser to set one, then connect with the new password.']
|
||||
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.']
|
||||
};
|
||||
|
||||
// The same two at localhost or 127.0.0.1, the only names Codeman's
|
||||
@@ -439,6 +501,9 @@
|
||||
}
|
||||
|
||||
function onProbeResult(msg) {
|
||||
// The answer belongs to the tab its connect was asked from, whichever one
|
||||
// shows by now; with no connect under way, to the one in view.
|
||||
var asked = state.busy ? state.askedFrom : state.tab;
|
||||
setBusy(false);
|
||||
hideSuggestion();
|
||||
var o = msg.outcome || {};
|
||||
@@ -447,9 +512,14 @@
|
||||
// The login fields live on the Another machine tab.
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
} else if (state.tab === 'local') {
|
||||
} else if (asked === 'local') {
|
||||
// Opening the Codeman on this Mac failed: look at it again.
|
||||
checkLocal('login');
|
||||
state.reopen = o.kind === 'refused';
|
||||
selectTab('local');
|
||||
// selectTab starts the look itself when there has been none yet.
|
||||
if (!state.localChecking) checkLocal('login');
|
||||
} else {
|
||||
selectTab('remote');
|
||||
}
|
||||
if (o.kind === 'notCodeman') {
|
||||
setStatus('bad', 'Something answered, but it is not a Codeman server (HTTP ' + o.status + ').', 'Check the port and, behind a proxy, the path Codeman is mounted under.');
|
||||
@@ -472,6 +542,9 @@
|
||||
}
|
||||
if (explainLoopback[o.kind] && loopbackName(msg.url)) detail = explainLoopback[o.kind];
|
||||
if (o.kind === 'tlsUntrusted' && o.detail) detail += '\n(' + o.detail + ')';
|
||||
if ((o.kind === 'needsLogin' || o.kind === 'badCredentials') && /^http:/i.test(normalize(msg.url)) && !isLocalUrl(msg.url)) {
|
||||
detail += ' This address is http://, so the password crosses the network unencrypted: fine on a tailnet or a network you trust.';
|
||||
}
|
||||
setStatus(e[0], e[1], detail);
|
||||
if (o.kind === 'maybeHttps' && /^http:/i.test(msg.url || '')) suggest(String(msg.url).replace(/^http:/i, 'https:'));
|
||||
} else {
|
||||
@@ -498,7 +571,13 @@
|
||||
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
// Look at this Mac, once, when the This Mac tab shows and nothing has yet.
|
||||
function ensureLocalChecked() {
|
||||
if (state.tab === 'local' && !state.local && !state.localChecking) checkLocal('login');
|
||||
}
|
||||
|
||||
// later: leave the look at this Mac to whoever asked (ensureLocalChecked).
|
||||
function selectTab(tab, later) {
|
||||
if (tab !== 'local' && tab !== 'remote') return;
|
||||
var changed = state.tab !== tab;
|
||||
state.tab = tab;
|
||||
@@ -509,15 +588,14 @@
|
||||
els.tabRemote.tabIndex = local ? -1 : 0;
|
||||
els.paneLocal.hidden = !local;
|
||||
els.form.hidden = local;
|
||||
els.elsewhere.hidden = local;
|
||||
if (changed && !state.busy) {
|
||||
setStatus('', '');
|
||||
hideSuggestion();
|
||||
}
|
||||
if (local && !state.local && !state.localChecking) checkLocal('login');
|
||||
if (!later) ensureLocalChecked();
|
||||
}
|
||||
|
||||
// quick: skip the login shell (used while an install runs, when only
|
||||
// "does it answer yet" matters).
|
||||
// How hard to look: 'quick' (files and processes, while an install runs),
|
||||
// 'login' (also the login shell; every automatic look) or 'deep' (also the
|
||||
// interactive profile, only on Check again).
|
||||
@@ -550,7 +628,7 @@
|
||||
title = 'Installing Codeman in Terminal…';
|
||||
detail = 'Answer the installer’s questions there. This window opens Codeman when it is done.';
|
||||
show.stop = true;
|
||||
} else if (state.localChecking || !s) {
|
||||
} else if (!s) {
|
||||
dot = 'busy';
|
||||
title = 'Looking for Codeman on this Mac…';
|
||||
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
@@ -558,6 +636,11 @@
|
||||
title = 'Codeman is being installed in Terminal.';
|
||||
detail = 'Finish the installer there, then Check again.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'starting') {
|
||||
dot = 'busy';
|
||||
title = 'Codeman is starting on this Mac…';
|
||||
detail = 'It will answer at ' + s.url + ' in a moment.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'runningElsewhere') {
|
||||
dot = 'warn';
|
||||
title = 'A Codeman server is already running on this Mac.';
|
||||
@@ -616,6 +699,8 @@
|
||||
}
|
||||
show.check = true;
|
||||
}
|
||||
// Looking again: what was found last stays on screen, so nothing jumps.
|
||||
if (state.localChecking && s && !state.wait) dot = 'busy';
|
||||
els.localDot.className = 'dot' + (dot ? ' ' + dot : '');
|
||||
els.localTitle.textContent = title;
|
||||
els.localDetail.textContent = detail;
|
||||
@@ -642,9 +727,36 @@
|
||||
onWaitStatus(state.local || {});
|
||||
return;
|
||||
}
|
||||
if (followStarting(s)) return;
|
||||
renderLocal();
|
||||
}
|
||||
|
||||
// A Codeman that is still starting (its process runs, but listens nowhere
|
||||
// yet: the first seconds after login, a restart): look again every second
|
||||
// for a while. Once it is up, open it if a connect to this Mac was refused
|
||||
// on the way here, which is what the user had asked for. True when it opened.
|
||||
function followStarting(s) {
|
||||
clearTimeout(state.startingTimer);
|
||||
if (s && s.state === 'starting') {
|
||||
if (state.startingLooks < STARTING_MAX_LOOKS) {
|
||||
state.startingLooks++;
|
||||
state.startingTimer = setTimeout(function () {
|
||||
if (state.local && state.local.state === 'starting' && !state.wait) checkLocal('quick');
|
||||
}, STARTING_POLL_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
|
||||
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
|
||||
state.startingLooks = 0;
|
||||
state.reopen = false;
|
||||
if (open) {
|
||||
renderLocal();
|
||||
openLocal();
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
// Point the Another machine form at `url` with only the login saved for it:
|
||||
// never the username or a typed password of the server the form showed
|
||||
// before, which connect() would otherwise send along.
|
||||
@@ -688,9 +800,6 @@
|
||||
state.wait = null;
|
||||
}
|
||||
|
||||
// One answer while an install runs. It stops at the first answer from a
|
||||
// server: a password-protected one counts every unauthenticated look as a
|
||||
// failed login.
|
||||
function portOf(url) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
@@ -698,6 +807,9 @@
|
||||
} catch (e) { return 0; }
|
||||
}
|
||||
|
||||
// One answer while an install runs. It stops at the first answer from a
|
||||
// server: a password-protected one counts every unauthenticated look as a
|
||||
// failed login.
|
||||
function onWaitStatus(s) {
|
||||
var w = state.wait;
|
||||
var now = Date.now();
|
||||
@@ -766,20 +878,25 @@
|
||||
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
|
||||
renderRecent();
|
||||
if (state.servers[0]) choose(state.servers[0]);
|
||||
// The tab of the server used last; This Mac on a first run.
|
||||
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local');
|
||||
var auto = !!(msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]);
|
||||
// The tab of the server used last; This Mac on a first run. An
|
||||
// automatic connect opens Codeman without looking at this Mac first:
|
||||
// that look runs a few processes and asks the server once more, and
|
||||
// it is only needed when the connect fails, which then asks for it.
|
||||
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local', auto);
|
||||
if (msg.starting) {
|
||||
setBusy(true, false);
|
||||
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
|
||||
} else if (msg.notice) {
|
||||
setStatus('warn', msg.notice);
|
||||
}
|
||||
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
|
||||
if (auto) connect();
|
||||
else if (state.tab === 'remote') els.url.focus();
|
||||
break;
|
||||
case 'servers':
|
||||
state.servers = msg.servers || [];
|
||||
renderRecent();
|
||||
if (!state.busy) matchSaved();
|
||||
break;
|
||||
case 'prefs':
|
||||
if (typeof msg.startFullscreen === 'boolean') els.fullscreen.checked = msg.startFullscreen;
|
||||
@@ -795,14 +912,18 @@
|
||||
} else {
|
||||
setStatus('ok', msg.version ? 'Opening Codeman ' + msg.version + '…' : 'Opening Codeman…');
|
||||
}
|
||||
// The server page is still loading: this is not a finished state.
|
||||
els.spinner.hidden = false;
|
||||
break;
|
||||
case 'error':
|
||||
setBusy(false);
|
||||
setStatus('bad', msg.message || 'Something went wrong.');
|
||||
// The tab first: switching it clears the status line.
|
||||
if (msg.needsPassword) {
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
}
|
||||
setStatus('bad', msg.message || 'Something went wrong.');
|
||||
ensureLocalChecked();
|
||||
break;
|
||||
case 'serverStarting':
|
||||
setBusy(true, false);
|
||||
@@ -833,6 +954,8 @@
|
||||
case 'installFailed':
|
||||
state.opening = false;
|
||||
syncLocalButtons();
|
||||
// The tab first: switching it clears the status line.
|
||||
if (msg.remote) selectTab('remote');
|
||||
setStatus('bad', msg.message || 'Could not open Terminal.');
|
||||
break;
|
||||
case 'copied':
|
||||
@@ -842,8 +965,22 @@
|
||||
}
|
||||
};
|
||||
|
||||
// The second click of a double click acts only on the control that took the
|
||||
// first: the first may have moved another one under the pointer.
|
||||
var firstControl = null;
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target && ev.target.closest ? ev.target : null;
|
||||
var c = t && (t.closest('label') || t.closest('button, summary, a, input'));
|
||||
if (ev.detail < 2) { firstControl = c; return; }
|
||||
if (c !== firstControl) { ev.preventDefault(); ev.stopPropagation(); }
|
||||
}, true);
|
||||
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
|
||||
els.cancel.addEventListener('click', function () { post({ cmd: 'cancel' }); setBusy(false); setStatus('', ''); });
|
||||
els.cancel.addEventListener('click', function () {
|
||||
post({ cmd: 'cancel' });
|
||||
setBusy(false);
|
||||
setStatus('', '');
|
||||
ensureLocalChecked();
|
||||
});
|
||||
els.start.addEventListener('click', function () {
|
||||
if (state.busy) return;
|
||||
state.startUrl = state.lastUrl || els.url.value.trim();
|
||||
@@ -873,6 +1010,19 @@
|
||||
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
|
||||
});
|
||||
});
|
||||
// Return opens or starts the Codeman on this Mac and Esc cancels a connect,
|
||||
// as in a Mac dialog. Install is left to a click: a stray Return must not
|
||||
// start an installer.
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.defaultPrevented) return;
|
||||
if (ev.key === 'Escape' && !els.cancel.hidden) { ev.preventDefault(); els.cancel.click(); return; }
|
||||
if (ev.key !== 'Enter' || state.tab !== 'local') return;
|
||||
if (ev.target && ev.target.closest && ev.target.closest('button, a, input, summary')) return;
|
||||
// offsetParent: really on screen, whatever hides it (its own `hidden`, or
|
||||
// a container that is not shown).
|
||||
var main = [els.localOpen, els.localStart].filter(function (b) { return b.offsetParent && !b.disabled; })[0];
|
||||
if (main) { ev.preventDefault(); main.click(); }
|
||||
});
|
||||
els.localOpen.addEventListener('click', openLocal);
|
||||
els.localStart.addEventListener('click', startLocal);
|
||||
els.localInstall.addEventListener('click', installLocal);
|
||||
|
||||
@@ -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")"
|
||||
@@ -282,6 +282,28 @@ mod tests {
|
||||
assert!(!HOST_SHIM.contains("decodeURIComponent"));
|
||||
}
|
||||
|
||||
/// The connect page refuses what the app would drop without an answer, so
|
||||
/// its two limits have to be the app's own.
|
||||
#[test]
|
||||
fn connect_page_keeps_the_message_limits() {
|
||||
let page = String::from_utf8_lossy(CONNECT_HTML);
|
||||
for needle in [
|
||||
format!("var MAX_URL = {};", crate::policy::MAX_URL_LEN),
|
||||
format!("var MAX_FIELD = {};", crate::policy::MAX_FIELD_LEN),
|
||||
] {
|
||||
assert!(page.contains(&needle), "{needle}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy puts setup::INSTALL_ONE_LINER on the clipboard, and the page shows
|
||||
/// the command as text of its own: the two have to be the same command.
|
||||
#[test]
|
||||
fn connect_page_shows_the_command_that_copy_copies() {
|
||||
let page = String::from_utf8_lossy(CONNECT_HTML);
|
||||
let shown = format!("<code id=\"oneliner\">{}</code>", crate::setup::INSTALL_ONE_LINER);
|
||||
assert!(page.contains(&shown), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_escapes_file_names() {
|
||||
let js = download_toast_script("a\"); alert(1); (\".txt", true);
|
||||
|
||||
@@ -25,10 +25,14 @@ const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0
|
||||
/// How much of the title bar must be on a screen to grab it.
|
||||
const MIN_GRIP: f64 = 80.0;
|
||||
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
|
||||
/// bounds, so the menu bar and the Dock are inside them; these are the margins
|
||||
/// main.rs uses for the centred default frame.
|
||||
/// bounds, so the menu bar and the Dock are inside them.
|
||||
const FIT_MARGIN_X: f64 = 40.0;
|
||||
const FIT_MARGIN_Y: f64 = 80.0;
|
||||
/// Room a first-run window leaves above its content (the menu bar, taller on
|
||||
/// a display with a notch, and its own title bar) and below it (a Dock of the
|
||||
/// default size): `Rect` is the whole display, so neither is taken off yet.
|
||||
const FIRST_RUN_ROOM_ABOVE: f64 = 66.0;
|
||||
const FIRST_RUN_ROOM_BELOW: f64 = 90.0;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -39,9 +43,17 @@ pub struct Config {
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// 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")]
|
||||
pub window: Option<WindowFrame>,
|
||||
}
|
||||
|
||||
fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowFrame>, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
@@ -92,6 +104,23 @@ pub struct Rect {
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
/// The frame of a first-run window on `screen`: `size` or what fits, never
|
||||
/// less than `min`, centred across and in the height between the menu bar
|
||||
/// and the Dock. Centring in the whole display put the bottom of a window
|
||||
/// under the Dock on a 900 pt high one.
|
||||
pub fn first_run(screen: &Rect, size: (f64, f64), min: (f64, f64)) -> WindowFrame {
|
||||
let width = size.0.min(screen.width - FIT_MARGIN_X).max(min.0);
|
||||
let room = screen.height - FIRST_RUN_ROOM_ABOVE - FIRST_RUN_ROOM_BELOW;
|
||||
let height = size.1.min(room).max(min.1);
|
||||
WindowFrame {
|
||||
x: screen.x + (screen.width - width) / 2.0,
|
||||
y: screen.y + FIRST_RUN_ROOM_ABOVE + ((room - height) / 2.0).max(0.0),
|
||||
width,
|
||||
height,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame clamped to sane sizes, or None when it holds junk.
|
||||
pub fn sanitized(self) -> Option<WindowFrame> {
|
||||
let vals = [self.x, self.y, self.width, self.height];
|
||||
@@ -626,6 +655,77 @@ mod tests {
|
||||
assert_eq!(c.servers[0].username, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_window_frame_keeps_the_servers() {
|
||||
for (name, window) in [
|
||||
("missing-height", r#"{"x":1,"y":2,"width":3}"#),
|
||||
("not-a-frame", r#""maximized""#),
|
||||
("null-number", r#"{"x":null,"y":2,"width":3,"height":4}"#),
|
||||
] {
|
||||
let dir = tmp_dir(&format!("bad-window-{name}"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(
|
||||
&path,
|
||||
format!(r#"{{"servers":[{{"url":"http://a/"}}],"autoConnect":false,"window":{window}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert!(can_save, "{name}");
|
||||
assert_eq!(c.servers.len(), 1, "{name}");
|
||||
assert!(!c.auto_connect, "{name}");
|
||||
assert_eq!(c.window, None, "{name}");
|
||||
// The file was read, so it stays where it is.
|
||||
assert!(path.is_file(), "{name}");
|
||||
assert!(!path.with_extension("json.corrupt").exists(), "{name}");
|
||||
}
|
||||
// A whole frame and an absent one read as before.
|
||||
let c: Config =
|
||||
serde_json::from_str(r#"{"window":{"x":1,"y":2,"width":900,"height":600,"fullscreen":true}}"#).unwrap();
|
||||
assert_eq!(c.window.map(|f| (f.width, f.fullscreen)), Some((900.0, true)));
|
||||
assert_eq!(serde_json::from_str::<Config>("{}").unwrap().window, None);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Config>(r#"{"window":null}"#).unwrap().window,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_window_sits_between_the_menu_bar_and_the_dock() {
|
||||
let screen = |width: f64, height: f64| Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let size = (1280.0, 820.0);
|
||||
let min = (480.0, 360.0);
|
||||
// A 13 inch MacBook: the window is shortened, and ends above the Dock.
|
||||
let f = WindowFrame::first_run(&screen(1440.0, 900.0), size, min);
|
||||
assert_eq!((f.width, f.height), (1280.0, 744.0));
|
||||
assert_eq!((f.x, f.y), (80.0, 66.0));
|
||||
assert!(f.y + f.height <= 900.0 - 90.0);
|
||||
// A large display: the full size, centred across and in the free height.
|
||||
let f = WindowFrame::first_run(&screen(2560.0, 1440.0), size, min);
|
||||
assert_eq!((f.width, f.height), (1280.0, 820.0));
|
||||
assert_eq!(f.x, 640.0);
|
||||
assert_eq!(f.y - 66.0, 1440.0 - 90.0 - (f.y + f.height));
|
||||
// A second display to the left of the first.
|
||||
let left = Rect {
|
||||
x: -1920.0,
|
||||
y: 120.0,
|
||||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
};
|
||||
let f = WindowFrame::first_run(&left, size, min);
|
||||
assert_eq!(f.x, -1920.0 + 320.0);
|
||||
assert!(f.y >= 120.0 + 66.0 && f.y + f.height <= 120.0 + 1080.0 - 90.0);
|
||||
// Never smaller than the minimum, on a display too small for it too.
|
||||
let f = WindowFrame::first_run(&screen(500.0, 400.0), size, min);
|
||||
assert_eq!((f.width, f.height), min);
|
||||
assert!(!f.fullscreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sanitizing_and_reachability() {
|
||||
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
|
||||
|
||||
@@ -189,9 +189,11 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Keychain on an in-memory store, for these tests and for the tests of the
|
||||
/// code that uses a Keychain (main.rs).
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{start, Backend, Job, Keychain};
|
||||
pub(crate) mod memory {
|
||||
use super::{start, Backend, Keychain};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -200,7 +202,7 @@ mod tests {
|
||||
/// purpose: a queue that did not keep order would let the next job
|
||||
/// overtake it, and the tests would see that.
|
||||
#[derive(Clone, Default)]
|
||||
struct Memory {
|
||||
pub(crate) struct Memory {
|
||||
items: Arc<Mutex<HashMap<String, String>>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
@@ -209,7 +211,8 @@ mod tests {
|
||||
fn note(&self, entry: String) {
|
||||
self.log.lock().unwrap().push(entry);
|
||||
}
|
||||
fn log(&self) -> Vec<String> {
|
||||
/// The jobs the store ran, in order.
|
||||
pub(crate) fn log(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
@@ -234,6 +237,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker on a fresh store, and the store to look into.
|
||||
pub(crate) fn keychain() -> (Keychain, Memory) {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert!(worker.is_some(), "the worker thread starts");
|
||||
(keychain, memory)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::memory::{keychain, Memory};
|
||||
use super::{start, Backend, Job, Keychain};
|
||||
|
||||
/// A store whose load fails the way a crashed worker would.
|
||||
struct Crashes;
|
||||
|
||||
@@ -247,13 +264,6 @@ mod tests {
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
|
||||
fn keychain() -> (Keychain, Memory) {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert!(worker.is_some(), "the worker thread starts");
|
||||
(keychain, memory)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_names_are_per_user_and_server() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -107,6 +107,10 @@ impl From<Output> for Run {
|
||||
fn from(out: Output) -> Self {
|
||||
let mut output = out.stdout;
|
||||
if !out.stderr.trim().is_empty() {
|
||||
// On a line of its own, also when stdout ended without a newline.
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&out.stderr);
|
||||
}
|
||||
Run {
|
||||
@@ -151,10 +155,16 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
pump(child.stdout.take(), 0, tx.clone());
|
||||
pump(child.stderr.take(), 1, tx);
|
||||
|
||||
// A login shell usually answers within tens of milliseconds: look often
|
||||
// at first, then back off.
|
||||
let mut nap = Duration::from_millis(5);
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(nap);
|
||||
nap = (nap * 2).min(Duration::from_millis(100));
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -265,6 +275,16 @@ fn capture_env(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run `cmd` in the home folder, where a terminal and the LaunchAgent start
|
||||
/// Codeman too. The server keeps the directory it was started in and uses it
|
||||
/// as the default for a session that names none; an app opened from the Finder
|
||||
/// or the Dock runs in `/`.
|
||||
fn in_home(cmd: &mut Command) {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
cmd.current_dir(home);
|
||||
}
|
||||
}
|
||||
|
||||
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
|
||||
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
|
||||
/// settings and nothing of launchd's bare one.
|
||||
@@ -272,6 +292,7 @@ fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadlin
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
|
||||
in_home(&mut cmd);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
@@ -289,6 +310,7 @@ fn run_in_shell(
|
||||
cmd.arg("-i");
|
||||
}
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
in_home(&mut cmd);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
@@ -400,6 +422,26 @@ mod tests {
|
||||
assert_eq!(last_lines("", 3), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_starts_on_a_line_of_its_own() {
|
||||
let said = |stdout: &str, stderr: &str| {
|
||||
Run::from(Output {
|
||||
code: Some(1),
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
})
|
||||
.output
|
||||
};
|
||||
// stdout that ended without a newline used to be glued to stderr.
|
||||
assert_eq!(
|
||||
said("Starting Codeman", "Error: port 3000 is in use\n"),
|
||||
"Starting Codeman\nError: port 3000 is in use"
|
||||
);
|
||||
assert_eq!(said("Starting\n", "Error\n"), "Starting\nError");
|
||||
assert_eq!(said("", "Error\n"), "Error");
|
||||
assert_eq!(said("Started\n", " \n"), "Started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_non_local_or_https() {
|
||||
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
|
||||
|
||||
@@ -161,14 +161,17 @@ fn origin_label(host: &str, port: i64) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The bold first line of a dialog, like Safari's, so a frame cannot pass its
|
||||
/// text off as the app's own. `embedded` is a sandboxed frame (an HTML file
|
||||
/// preview, an untrusted web tab): its origin is opaque, so `origin` is only
|
||||
/// the server it was loaded from, and it must not read as Codeman speaking.
|
||||
fn dialog_title(origin: &str, embedded: bool) -> String {
|
||||
if embedded {
|
||||
format!("An embedded page on {origin} says:")
|
||||
} else {
|
||||
format!("The page at {origin} says:")
|
||||
/// text off as the app's own: the page's text always goes below it. `frame` is
|
||||
/// the host that names the frame and whether it is embedded, which a sandboxed
|
||||
/// frame is (an HTML file preview, an untrusted web tab): its origin is opaque,
|
||||
/// so the host is only the server it was loaded from, and it must not read as
|
||||
/// Codeman speaking. `None` is a frame nothing names: an opaque origin whose
|
||||
/// document has no host either (`about:srcdoc`, a `data:` or `blob:` URL).
|
||||
fn dialog_title(frame: Option<(&str, bool)>) -> String {
|
||||
match frame {
|
||||
Some((origin, false)) => format!("The page at {origin} says:"),
|
||||
Some((origin, true)) => format!("An embedded page on {origin} says:"),
|
||||
None => "An embedded page says:".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,13 +577,11 @@ mod imp {
|
||||
Some(message) => clipped(message),
|
||||
None => NSString::from_str(""),
|
||||
};
|
||||
match frame.and_then(frame_origin) {
|
||||
Some((origin, embedded)) => {
|
||||
alert.setMessageText(&NSString::from_str(&dialog_title(&origin, embedded)));
|
||||
alert.setInformativeText(&body);
|
||||
}
|
||||
None => alert.setMessageText(&body),
|
||||
}
|
||||
// The bold line is always the app's own; the page's text goes below.
|
||||
let named = frame.and_then(frame_origin);
|
||||
let title = dialog_title(named.as_ref().map(|(origin, embedded)| (origin.as_str(), *embedded)));
|
||||
alert.setMessageText(&NSString::from_str(&title));
|
||||
alert.setInformativeText(&body);
|
||||
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
|
||||
if panel != Panel::Alert {
|
||||
let _ = alert.addButtonWithTitle(&NSString::from_str("Cancel"));
|
||||
@@ -623,16 +624,18 @@ mod imp {
|
||||
}
|
||||
|
||||
/// The host (and port) of the frame that opened a dialog: its security
|
||||
/// origin, else its request URL's host. The flag is set when the origin is
|
||||
/// opaque (a sandboxed frame), where only the URL names a host.
|
||||
/// origin, else its request URL's host. The flag says "embedded". It is
|
||||
/// set for every sub-frame, whatever its origin (a trusted web tab is
|
||||
/// proxied under Codeman's own origin and would read as Codeman
|
||||
/// otherwise), and whenever the origin names no host, which is an opaque
|
||||
/// origin (a sandboxed frame) or no origin at all: a frame the app is
|
||||
/// unsure about never speaks as the page.
|
||||
fn frame_origin(frame: &WKFrameInfo) -> Option<(String, bool)> {
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY (every read here): property reads on objects WebKit handed
|
||||
// us, with nil handled by the `Option` return types.
|
||||
let sub_frame = !unsafe { frame.isMainFrame() };
|
||||
let origin: Option<Retained<WKSecurityOrigin>> = unsafe { msg_send![frame, securityOrigin] };
|
||||
// An origin without a host is opaque; when unsure, say "embedded"
|
||||
// rather than let a frame speak as the page.
|
||||
let opaque = origin.is_some();
|
||||
if let Some(origin) = origin {
|
||||
let host: Option<Retained<NSString>> = unsafe { msg_send![&*origin, host] };
|
||||
let port: NSInteger = unsafe { msg_send![&*origin, port] };
|
||||
@@ -641,7 +644,7 @@ mod imp {
|
||||
.and_then(rust_string)
|
||||
.and_then(|h| origin_label(&h, port as i64))
|
||||
{
|
||||
return Some((label, false));
|
||||
return Some((label, sub_frame));
|
||||
}
|
||||
}
|
||||
let request: Option<Retained<NSURLRequest>> = unsafe { msg_send![frame, request] };
|
||||
@@ -650,7 +653,7 @@ mod imp {
|
||||
let url = url?;
|
||||
let host: Option<Retained<NSString>> = unsafe { msg_send![&*url, host] };
|
||||
let host = host?;
|
||||
Some((origin_label(&rust_string(&host)?, 0)?, opaque))
|
||||
Some((origin_label(&rust_string(&host)?, 0)?, true))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1042,12 +1045,15 @@ mod tests {
|
||||
assert_eq!(origin_label("::1", 3000).as_deref(), Some("[::1]:3000"));
|
||||
assert_eq!(origin_label(" ", 3000), None);
|
||||
assert_eq!(
|
||||
dialog_title("localhost:3000", false),
|
||||
dialog_title(Some(("localhost:3000", false))),
|
||||
"The page at localhost:3000 says:"
|
||||
);
|
||||
assert_eq!(
|
||||
dialog_title("localhost:3000", true),
|
||||
dialog_title(Some(("localhost:3000", true))),
|
||||
"An embedded page on localhost:3000 says:"
|
||||
);
|
||||
// A frame nothing names (about:srcdoc under an opaque origin) still
|
||||
// gets a first line of the app's own, never its own text in bold.
|
||||
assert_eq!(dialog_title(None), "An embedded page says:");
|
||||
}
|
||||
}
|
||||
+331
-56
@@ -51,7 +51,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView
|
||||
|
||||
use config::{Config, Rect, WindowFrame};
|
||||
use credentials::Keychain;
|
||||
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Via};
|
||||
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via};
|
||||
use probe::ProbeOutcome;
|
||||
|
||||
const APP_NAME: &str = "Codeman";
|
||||
@@ -60,6 +60,9 @@ const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0);
|
||||
const POPOUT_SIZE: (f64, f64) = (960.0, 680.0);
|
||||
const MIN_SIZE: (f64, f64) = (480.0, 360.0);
|
||||
const MAX_POPOUTS: usize = 24;
|
||||
/// How far a pop-out opens down and to the right of the window in use, per
|
||||
/// pop-out already open (it starts over after eight).
|
||||
const POPOUT_STEP: f64 = 28.0;
|
||||
const SAVE_DEBOUNCE: Duration = Duration::from_secs(1);
|
||||
/// How long a server load may take to commit before the app gives up on it
|
||||
/// and brings the connect page back.
|
||||
@@ -68,6 +71,10 @@ const LOAD_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// page reported that its session is gone, so a login the server keeps
|
||||
/// refusing can never turn into a reload loop.
|
||||
const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
/// A window loads its page again at most once in this long after its web
|
||||
/// content process ended, so a page that cannot stay up never becomes a reload
|
||||
/// loop.
|
||||
const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30);
|
||||
/// When the app first looks whether the main window made it into the full
|
||||
/// screen it asked for at launch, how often it asks again, and how many times.
|
||||
/// tao retries a failed entry only for a window BUILT in full screen, which
|
||||
@@ -88,6 +95,7 @@ const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter th
|
||||
const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \
|
||||
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 START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
|
||||
|
||||
mod menu_id {
|
||||
@@ -157,6 +165,12 @@ enum UserEvent {
|
||||
/// The challenge handler sent its login and got challenged again (macOS).
|
||||
/// Not trusted on its own: recheck_login asks the server first.
|
||||
AuthRejected,
|
||||
/// The web content process of a window ended (macOS): a crash, or the
|
||||
/// system taking it back under memory pressure. The window is blank until
|
||||
/// something loads in it again (on_content_process_gone).
|
||||
ContentProcessGone {
|
||||
window: WindowId,
|
||||
},
|
||||
/// A background step for the connect page finished (This Mac status, an
|
||||
/// install opened in Terminal, the install command copied): the message
|
||||
/// goes to the page if it is still the one choosing a server.
|
||||
@@ -211,6 +225,8 @@ struct AppWindow {
|
||||
committed: Option<Url>,
|
||||
/// When the window last reloaded itself because its session expired.
|
||||
session_reload_at: Option<Instant>,
|
||||
/// When the window's web content process last ended.
|
||||
content_process_gone_at: Option<Instant>,
|
||||
}
|
||||
|
||||
struct AppMenu {
|
||||
@@ -232,7 +248,10 @@ struct App {
|
||||
/// aside, in which case it must not be overwritten.
|
||||
config_path: Option<PathBuf>,
|
||||
config_dirty: bool,
|
||||
last_save: Instant,
|
||||
/// The settings are written SAVE_DEBOUNCE after this: the last write, or
|
||||
/// the last move or resize of the main window, so a drag writes the file
|
||||
/// once when it ends, not once a second while it goes on.
|
||||
save_held_since: Instant,
|
||||
/// Where the main window is in reaching a server. Shared with the web view
|
||||
/// handlers, which run on this same thread.
|
||||
phase: Rc<RefCell<Phase>>,
|
||||
@@ -309,7 +328,7 @@ fn main() {
|
||||
|
||||
let (window, wants_fullscreen) =
|
||||
create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}")));
|
||||
let webview = build_webview(&window, true, Load::ConnectPage { manual: false }, &proxy, &phase)
|
||||
let webview = build_webview(&window, true, true, Load::ConnectPage { manual: false }, &proxy, &phase)
|
||||
.unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}")));
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::install(&webview);
|
||||
@@ -324,6 +343,7 @@ fn main() {
|
||||
kind: Kind::Main,
|
||||
committed: None,
|
||||
session_reload_at: None,
|
||||
content_process_gone_at: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -332,7 +352,7 @@ fn main() {
|
||||
config,
|
||||
config_path,
|
||||
config_dirty: false,
|
||||
last_save: Instant::now(),
|
||||
save_held_since: Instant::now(),
|
||||
phase,
|
||||
loading_deadline: None,
|
||||
auth: None,
|
||||
@@ -468,21 +488,21 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A display's whole area in points (the menu bar and the Dock are inside it).
|
||||
fn display_rect(monitor: &tao::monitor::MonitorHandle) -> Rect {
|
||||
let sf = monitor.scale_factor();
|
||||
let p = monitor.position().to_logical::<f64>(sf);
|
||||
let s = monitor.size().to_logical::<f64>(sf);
|
||||
Rect {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn monitors(target: &EventLoopWindowTarget<UserEvent>) -> Vec<Rect> {
|
||||
target
|
||||
.available_monitors()
|
||||
.map(|m| {
|
||||
let sf = m.scale_factor();
|
||||
let p = m.position().to_logical::<f64>(sf);
|
||||
let s = m.size().to_logical::<f64>(sf);
|
||||
Rect {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
target.available_monitors().map(|m| display_rect(&m)).collect()
|
||||
}
|
||||
|
||||
/// The main window, and whether it was asked into full screen (App's
|
||||
@@ -505,17 +525,10 @@ fn create_main_window(
|
||||
None => {
|
||||
builder = builder.with_inner_size(LogicalSize::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1));
|
||||
if let Some(m) = target.primary_monitor() {
|
||||
let sf = m.scale_factor();
|
||||
let p = m.position().to_logical::<f64>(sf);
|
||||
let s = m.size().to_logical::<f64>(sf);
|
||||
let w = DEFAULT_SIZE.0.min(s.width - 40.0);
|
||||
let h = DEFAULT_SIZE.1.min(s.height - 80.0);
|
||||
let f = WindowFrame::first_run(&display_rect(&m), DEFAULT_SIZE, MIN_SIZE);
|
||||
builder = builder
|
||||
.with_inner_size(LogicalSize::new(w.max(MIN_SIZE.0), h.max(MIN_SIZE.1)))
|
||||
.with_position(LogicalPosition::new(
|
||||
p.x + (s.width - w) / 2.0,
|
||||
p.y + (s.height - h) / 2.0,
|
||||
));
|
||||
.with_inner_size(LogicalSize::new(f.width, f.height))
|
||||
.with_position(LogicalPosition::new(f.x, f.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,9 +609,13 @@ fn loaded_on_commit(phase: &Phase, committed: &str) -> Option<Phase> {
|
||||
policy::same_origin(&url, server).then(|| Phase::Loaded(server.clone()))
|
||||
}
|
||||
|
||||
/// `focused`: the web view takes the keyboard and brings the app to the front
|
||||
/// (wry's default). False for a window a script opened: it comes up without
|
||||
/// pulling the user out of the app they are typing in.
|
||||
fn build_webview(
|
||||
window: &Window,
|
||||
is_main: bool,
|
||||
focused: bool,
|
||||
load: Load<'_>,
|
||||
proxy: &EventLoopProxy<UserEvent>,
|
||||
phase: &Rc<RefCell<Phase>>,
|
||||
@@ -607,6 +624,7 @@ fn build_webview(
|
||||
let mut builder = WebViewBuilder::new()
|
||||
.with_initialization_script(assets::HOST_SHIM)
|
||||
.with_devtools(true)
|
||||
.with_focused(focused)
|
||||
.with_accept_first_mouse(true)
|
||||
.with_back_forward_navigation_gestures(false)
|
||||
// false = let WebKit take the drop, so Codeman's own HTML5 drop
|
||||
@@ -714,6 +732,17 @@ fn build_webview(
|
||||
});
|
||||
});
|
||||
|
||||
// wry answers WebKit's "web content process ended" notice itself, which
|
||||
// turns WebKit's own reload off: without a handler the window stays blank.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use wry::WebViewBuilderExtDarwin;
|
||||
let gone_proxy = proxy.clone();
|
||||
builder = builder.with_on_web_content_process_terminate_handler(move || {
|
||||
let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id });
|
||||
});
|
||||
}
|
||||
|
||||
builder = match load {
|
||||
Load::ConnectPage { manual: false } => builder.with_url(policy::CONNECT_PAGE),
|
||||
Load::ConnectPage { manual: true } => builder.with_url(format!("{}?manual=1", policy::CONNECT_PAGE)),
|
||||
@@ -762,7 +791,7 @@ impl App {
|
||||
/// so it is only decided here, never reset per event.
|
||||
fn on_events_cleared(&mut self, control_flow: &mut ControlFlow) {
|
||||
let now = Instant::now();
|
||||
if self.config_dirty && now >= self.last_save + SAVE_DEBOUNCE {
|
||||
if self.config_dirty && now >= self.save_held_since + SAVE_DEBOUNCE {
|
||||
self.save_config();
|
||||
}
|
||||
let loading = matches!(*self.phase.borrow(), Phase::Loading(_));
|
||||
@@ -777,7 +806,7 @@ impl App {
|
||||
if matches!(*control_flow, ControlFlow::ExitWithCode(_)) {
|
||||
return;
|
||||
}
|
||||
let save_due = self.config_dirty.then(|| self.last_save + SAVE_DEBOUNCE);
|
||||
let save_due = self.config_dirty.then(|| self.save_held_since + SAVE_DEBOUNCE);
|
||||
let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due);
|
||||
*control_flow = match [save_due, self.loading_deadline, fullscreen_due]
|
||||
.into_iter()
|
||||
@@ -973,6 +1002,45 @@ impl App {
|
||||
}
|
||||
}
|
||||
UserEvent::AuthRejected => self.recheck_login(),
|
||||
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
|
||||
}
|
||||
}
|
||||
|
||||
/// The web content process of `window` ended, so the window is blank
|
||||
/// (policy::recovery decides what it does about it). A second end within
|
||||
/// CRASH_RELOAD_COOLDOWN counts as the same page failing again.
|
||||
fn on_content_process_gone(&mut self, window: WindowId) {
|
||||
let now = Instant::now();
|
||||
let Some(w) = self.windows.get_mut(&window) else {
|
||||
return;
|
||||
};
|
||||
// A pop-out hidden for its download shows no page, and the download
|
||||
// does not run in that process: loading again would fetch it twice.
|
||||
if matches!(
|
||||
w.kind,
|
||||
Kind::Popout {
|
||||
hidden_for_download: true,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let again = w
|
||||
.content_process_gone_at
|
||||
.is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN);
|
||||
w.content_process_gone_at = Some(now);
|
||||
let on_server = self.phase.borrow().loaded().is_some();
|
||||
match policy::recovery(window == self.main_id, on_server, again) {
|
||||
Recovery::Reload => {
|
||||
if let Some((url, with_auth)) = self.reload_target(window) {
|
||||
self.load_in(window, &url, with_auth);
|
||||
}
|
||||
}
|
||||
Recovery::ConnectPage { gave_up } => {
|
||||
self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false);
|
||||
}
|
||||
Recovery::Close => self.remove_window(window),
|
||||
Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1267,25 +1335,8 @@ impl App {
|
||||
.as_ref()
|
||||
.and_then(|u| keychain.load_blocking(&credentials::account(&key, u))),
|
||||
};
|
||||
let (user, password, source) = match typed_password {
|
||||
Some(p) => (user, Some(p), PasswordSource::Typed),
|
||||
None => match saved {
|
||||
Some(p) => (user, Some(p), PasswordSource::Keychain),
|
||||
// The Codeman on this Mac keeps its login where Codeman's
|
||||
// own clients find it (setup::local_login: its launchd
|
||||
// job, then ~/.codeman/.env), and only answers it at its
|
||||
// own loopback address. Used when nothing was typed and
|
||||
// the Keychain has nothing for this server, for the same
|
||||
// username only, and never saved anywhere.
|
||||
None if !expect_saved => match setup::local_login(&url) {
|
||||
Some((u, p)) if user.as_deref().is_none_or(|current| current == u) => {
|
||||
(Some(u), Some(p), PasswordSource::LocalServer)
|
||||
}
|
||||
_ => (user, None, PasswordSource::Absent),
|
||||
},
|
||||
None => (user, None, PasswordSource::Absent),
|
||||
},
|
||||
};
|
||||
let (user, password, source) =
|
||||
choose_login(user, typed_password, saved, expect_saved, || setup::local_login(&url));
|
||||
// Codeman's default username when only a password was given.
|
||||
let username = password
|
||||
.as_ref()
|
||||
@@ -1572,14 +1623,33 @@ impl App {
|
||||
}
|
||||
let opened_url = policy::webkit_safe_url(url);
|
||||
let auth = self.auth.clone().filter(|_| policy::within_base(url, &server));
|
||||
let window = match WindowBuilder::new()
|
||||
// tao centres a window that is given no position, so every pop-out
|
||||
// would open exactly on top of the one before. Each one steps off the
|
||||
// window in use instead.
|
||||
let position = self
|
||||
.focused_window_id()
|
||||
.or(Some(self.main_id))
|
||||
.and_then(|id| self.windows.get(&id))
|
||||
.and_then(|w| {
|
||||
// The corner tao's with_position places (see record_main_frame).
|
||||
#[cfg(target_os = "macos")]
|
||||
let corner = w.window.inner_position();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let corner = w.window.outer_position();
|
||||
let p = corner.ok()?.to_logical::<f64>(w.window.scale_factor());
|
||||
let step = POPOUT_STEP * (1 + popouts % 8) as f64;
|
||||
Some(LogicalPosition::new(p.x + step, p.y + step))
|
||||
});
|
||||
let mut builder = WindowBuilder::new()
|
||||
.with_title(APP_NAME)
|
||||
.with_theme(Some(Theme::Dark))
|
||||
.with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1))
|
||||
.with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1))
|
||||
.with_focused(focused)
|
||||
.build(target)
|
||||
{
|
||||
.with_focused(focused);
|
||||
if let Some(position) = position {
|
||||
builder = builder.with_position(position);
|
||||
}
|
||||
let window = match builder.build(target) {
|
||||
Ok(window) => window,
|
||||
Err(e) => {
|
||||
eprintln!("codeman-desktop: could not open a window: {e}");
|
||||
@@ -1590,8 +1660,15 @@ impl App {
|
||||
url: &opened_url,
|
||||
auth: auth.as_deref(),
|
||||
};
|
||||
let webview = match build_webview(&window, false, load, &self.proxy, &self.phase) {
|
||||
Ok(webview) => webview,
|
||||
let webview = match build_webview(&window, false, focused, load, &self.proxy, &self.phase) {
|
||||
Ok(webview) => {
|
||||
// Still the one that takes the keys once the user comes to
|
||||
// this window: first responder in its own window only.
|
||||
if !focused {
|
||||
let _ = webview.focus();
|
||||
}
|
||||
webview
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("codeman-desktop: could not create a web view: {e}");
|
||||
return false;
|
||||
@@ -1610,6 +1687,7 @@ impl App {
|
||||
},
|
||||
committed: None,
|
||||
session_reload_at: None,
|
||||
content_process_gone_at: None,
|
||||
},
|
||||
);
|
||||
true
|
||||
@@ -1730,11 +1808,12 @@ impl App {
|
||||
if self.config.window != Some(frame) {
|
||||
self.config.window = Some(frame);
|
||||
self.config_dirty = true;
|
||||
self.save_held_since = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn save_config(&mut self) {
|
||||
self.last_save = Instant::now();
|
||||
self.save_held_since = Instant::now();
|
||||
self.config_dirty = false;
|
||||
if let Some(path) = &self.config_path {
|
||||
if let Err(e) = self.config.save_to(path) {
|
||||
@@ -1764,6 +1843,38 @@ impl App {
|
||||
}
|
||||
|
||||
/// A login the probe just tried, and what the user asked for it.
|
||||
/// The login a connect goes out with, and where its password came from. A
|
||||
/// typed password wins, then the one the Keychain holds for this user. Last
|
||||
/// comes the login of the Codeman on this Mac, which keeps it where its own
|
||||
/// clients find it (setup::local_login: its launchd job, then
|
||||
/// ~/.codeman/.env) and only answers it at its own loopback address. That one
|
||||
/// is used only when the config expected no password in the Keychain
|
||||
/// (`expect_saved`: one that should be there and is not is reported, not
|
||||
/// papered over), only for the username in use when there is one, and it is
|
||||
/// never saved anywhere. `local` is asked only then: it runs processes.
|
||||
fn choose_login(
|
||||
user: Option<String>,
|
||||
typed_password: Option<String>,
|
||||
saved: Option<String>,
|
||||
expect_saved: bool,
|
||||
local: impl FnOnce() -> Option<(String, String)>,
|
||||
) -> (Option<String>, Option<String>, PasswordSource) {
|
||||
if let Some(password) = typed_password {
|
||||
return (user, Some(password), PasswordSource::Typed);
|
||||
}
|
||||
if let Some(password) = saved {
|
||||
return (user, Some(password), PasswordSource::Keychain);
|
||||
}
|
||||
if !expect_saved {
|
||||
if let Some((local_user, password)) = local() {
|
||||
if user.as_deref().is_none_or(|current| current == local_user) {
|
||||
return (Some(local_user), Some(password), PasswordSource::LocalServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
(user, None, PasswordSource::Absent)
|
||||
}
|
||||
|
||||
struct ProbedLogin<'a> {
|
||||
/// The server, as the config and the Keychain accounts key it.
|
||||
server: &'a str,
|
||||
@@ -1843,3 +1954,167 @@ fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLo
|
||||
update.saved = saved;
|
||||
update
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::credentials::memory;
|
||||
|
||||
fn none() -> Option<(String, String)> {
|
||||
panic!("the local login must not be looked up here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_login_a_connect_uses() {
|
||||
let s = |v: &str| Some(v.to_string());
|
||||
let local = || Some(("admin".to_string(), "from-this-mac".to_string()));
|
||||
|
||||
// A typed password wins over everything, and nothing else is asked.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), s("typed"), s("kept"), true, none),
|
||||
(s("kay"), s("typed"), PasswordSource::Typed)
|
||||
);
|
||||
// Then the Keychain's.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), None, s("kept"), true, none),
|
||||
(s("kay"), s("kept"), PasswordSource::Keychain)
|
||||
);
|
||||
// The Keychain should hold one and does not: reported as absent, the
|
||||
// local login is not tried in its place.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), None, None, true, none),
|
||||
(s("kay"), None, PasswordSource::Absent)
|
||||
);
|
||||
// Nothing typed, nothing expected: the Codeman on this Mac's own
|
||||
// login, for no username or the same one.
|
||||
assert_eq!(
|
||||
choose_login(None, None, None, false, local),
|
||||
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
|
||||
);
|
||||
assert_eq!(
|
||||
choose_login(s("admin"), None, None, false, local),
|
||||
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
|
||||
);
|
||||
// Never for another user: the names are compared exactly.
|
||||
assert_eq!(
|
||||
choose_login(s("kay"), None, None, false, local),
|
||||
(s("kay"), None, PasswordSource::Absent)
|
||||
);
|
||||
assert_eq!(
|
||||
choose_login(s("Admin"), None, None, false, local),
|
||||
(s("Admin"), None, PasswordSource::Absent)
|
||||
);
|
||||
// No local login either.
|
||||
assert_eq!(
|
||||
choose_login(None, None, None, false, || None),
|
||||
(None, None, PasswordSource::Absent)
|
||||
);
|
||||
}
|
||||
|
||||
/// update_keychain for one probe answer; the log is what the Keychain did,
|
||||
/// in order (the last load only waits for the queue to drain).
|
||||
fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec<String>) {
|
||||
let (keychain, store) = memory::keychain();
|
||||
let update = update_keychain(&keychain, &outcome, &login);
|
||||
keychain.load_blocking("done");
|
||||
let mut log = store.log();
|
||||
assert_eq!(log.pop().as_deref(), Some("load done"));
|
||||
(update, log)
|
||||
}
|
||||
|
||||
fn login<'a>(
|
||||
previous_user: Option<&'a str>,
|
||||
username: Option<&'a str>,
|
||||
source: PasswordSource,
|
||||
remember: bool,
|
||||
) -> ProbedLogin<'a> {
|
||||
ProbedLogin {
|
||||
server: "http://a/",
|
||||
previous_user,
|
||||
username,
|
||||
password: (source != PasswordSource::Absent).then_some("secret"),
|
||||
source,
|
||||
remember,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_keychain_keeps_after_a_connect() {
|
||||
let ok = || ProbeOutcome::Ok {
|
||||
version: "1.48.0".into(),
|
||||
};
|
||||
let item = |user: &str| credentials::account("http://a/", user);
|
||||
|
||||
// A typed password is saved when Remember is on, deleted when it is off.
|
||||
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true));
|
||||
assert!(update.saved && update.deleted.is_empty());
|
||||
assert_eq!(log, [format!("save {}", item("kay"))]);
|
||||
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false));
|
||||
assert!(!update.saved);
|
||||
assert_eq!(update.deleted, ["kay"]);
|
||||
assert_eq!(log, [format!("delete {}", item("kay"))]);
|
||||
|
||||
// One that came from the Keychain is there already: nothing is written.
|
||||
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true));
|
||||
assert!(update.saved && log.is_empty());
|
||||
|
||||
// The local Codeman's own login never gets a copy, Remember or not.
|
||||
let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true));
|
||||
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
|
||||
|
||||
// Another user than before: the old user's item goes FIRST, then the save.
|
||||
let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true));
|
||||
assert!(update.saved);
|
||||
assert_eq!(update.deleted, ["old"]);
|
||||
assert_eq!(
|
||||
log,
|
||||
[format!("delete {}", item("old")), format!("save {}", item("new"))]
|
||||
);
|
||||
|
||||
// A password the server wants changed is never kept.
|
||||
let (update, log) = keychain_after(
|
||||
ProbeOutcome::PasswordChangeRequired,
|
||||
login(Some("kay"), Some("kay"), PasswordSource::Typed, true),
|
||||
);
|
||||
assert!(!update.saved);
|
||||
assert_eq!(update.deleted, ["kay"]);
|
||||
assert_eq!(log, [format!("delete {}", item("kay"))]);
|
||||
|
||||
// A login the server did not accept changes nothing.
|
||||
for refused in [
|
||||
ProbeOutcome::BadCredentials,
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::Refused,
|
||||
ProbeOutcome::Timeout,
|
||||
] {
|
||||
let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true));
|
||||
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_from_the_loading_server_is_the_loaded_page() {
|
||||
let server = Url::parse("https://box.ts.net/codeman/").unwrap();
|
||||
let loading = Phase::Loading(server.clone());
|
||||
assert_eq!(
|
||||
loaded_on_commit(&loading, "https://box.ts.net/codeman/"),
|
||||
Some(Phase::Loaded(server.clone()))
|
||||
);
|
||||
// Any page of that origin counts: the server may redirect within it.
|
||||
assert_eq!(
|
||||
loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"),
|
||||
Some(Phase::Loaded(server.clone()))
|
||||
);
|
||||
// Another origin, the connect page, or junk: still loading.
|
||||
assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None);
|
||||
assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None);
|
||||
assert_eq!(loaded_on_commit(&loading, policy::CONNECT_PAGE), None);
|
||||
assert_eq!(loaded_on_commit(&loading, "not a url"), None);
|
||||
// Only a load under way can finish.
|
||||
assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None);
|
||||
assert_eq!(
|
||||
loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
+134
-15
@@ -19,8 +19,8 @@ pub const APP_SCHEME: &str = "codemanapp";
|
||||
pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html";
|
||||
|
||||
/// Caps on what a page may hand the native side.
|
||||
const MAX_URL_LEN: usize = 4096;
|
||||
const MAX_FIELD_LEN: usize = 1024;
|
||||
pub const MAX_URL_LEN: usize = 4096;
|
||||
pub const MAX_FIELD_LEN: usize = 1024;
|
||||
|
||||
/// Where the main window is in reaching a server. The gates below take it
|
||||
/// instead of "is a server set", because what may talk to the native side
|
||||
@@ -61,9 +61,14 @@ impl Phase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn what the user typed into a server URL: scheme added when missing
|
||||
/// (`https` for a tailnet `.ts.net` name, `http` otherwise), query and fragment
|
||||
/// dropped, and the path ending in `/` so `api/status` joins below a base path.
|
||||
/// Turn what the user typed into a server URL: scheme added when missing,
|
||||
/// query and fragment dropped, and the path ending in `/` so `api/status`
|
||||
/// joins below a base path. The scheme is `https` for a tailnet `.ts.net`
|
||||
/// name on its own, which is `tailscale serve`, and `http` otherwise. A
|
||||
/// `.ts.net` name with a port is Codeman's own listener, which speaks plain
|
||||
/// http: guessing https there ended in a TLS error with no way forward, while
|
||||
/// the other mistake (http to an https port) gets a button for the https
|
||||
/// address (ProbeOutcome::MaybeHttps).
|
||||
pub fn normalize_server_url(input: &str) -> Result<Url, String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
@@ -75,8 +80,8 @@ pub fn normalize_server_url(input: &str) -> Result<Url, String> {
|
||||
let with_scheme = if s.contains("://") {
|
||||
s.to_string()
|
||||
} else {
|
||||
let host = s.split(['/', ':']).next().unwrap_or("");
|
||||
let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") {
|
||||
let (host, rest) = s.split_at(s.find(['/', ':']).unwrap_or(s.len()));
|
||||
let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") && !rest.starts_with(':') {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
@@ -112,9 +117,15 @@ pub fn same_origin(a: &Url, b: &Url) -> bool {
|
||||
/// Basic `Authorization` header goes only to such URLs, so another app sharing
|
||||
/// the origin (`/grafana/` next to `/codeman/`) never receives the password.
|
||||
/// `server.path()` always ends in `/` (normalize_server_url), so `/codemanx/`
|
||||
/// is not below `/codeman/`.
|
||||
/// is not below `/codeman/`. The part below the base is decoded once, as
|
||||
/// is_popout_target does, and may hold no `.` or `..` segment: the url crate
|
||||
/// resolves a literal `..`, but `..%2Fgrafana/` reaches a proxy that decodes
|
||||
/// before it routes as a way out of the base. A path that does not decode is
|
||||
/// not below it either (fail closed).
|
||||
pub fn within_base(url: &Url, server: &Url) -> bool {
|
||||
same_origin(url, server) && url.path().starts_with(server.path())
|
||||
same_origin(url, server)
|
||||
&& below_base_decoded(url, server)
|
||||
.is_some_and(|rest| !rest.split(['/', '\\']).any(|segment| segment == "." || segment == ".."))
|
||||
}
|
||||
|
||||
/// A server on this machine: `localhost`, `127.0.0.0/8` or `::1`.
|
||||
@@ -186,8 +197,9 @@ pub fn trusted_frame(frame: &Url, server: &Url) -> bool {
|
||||
/// (`session/<id>`), a rendered HTML file (`html-view/...`, served with a
|
||||
/// sandbox CSP), a detached file preview or attachment (`api/sessions/<id>/`
|
||||
/// `file-raw`, `file-preview`, `attachments/<id>/raw|preview`,
|
||||
/// `folders/<id>/files/...`) and a download (`api/download`). A script's
|
||||
/// `window.open` gets only the last two kinds (new_window, Via::Script).
|
||||
/// `folders/<id>/files/...`, and the path picker's `api/filesystem/preview`)
|
||||
/// and a download (`api/download`). A script's `window.open` gets only the
|
||||
/// last two kinds (new_window, Via::Script).
|
||||
///
|
||||
/// Everything else on the origin is refused, a proxied dashboard under
|
||||
/// `webview/` above all. A browser keeps the opener frame's sandbox on a popup;
|
||||
@@ -222,8 +234,8 @@ enum Popout {
|
||||
Session(String),
|
||||
/// `html-view/...`: a rendered HTML file.
|
||||
HtmlView,
|
||||
/// A detached file preview, an attachment, a file in a granted folder, or
|
||||
/// a download (`api/download`).
|
||||
/// A detached file preview, an attachment, a file in a granted folder,
|
||||
/// the path picker's preview, or a download (`api/download`).
|
||||
File,
|
||||
}
|
||||
|
||||
@@ -245,14 +257,14 @@ fn popout_kind(url: &Url, server: &Url) -> Option<Popout> {
|
||||
}
|
||||
match segments.as_slice() {
|
||||
["session", id] if !id.is_empty() => Some(Popout::Session((*id).to_string())),
|
||||
["api", "download"] => Some(Popout::File),
|
||||
["api", "download"] | ["api", "filesystem", "preview"] => Some(Popout::File),
|
||||
["api", "sessions", id, "file-raw" | "file-preview"] if !id.is_empty() => Some(Popout::File),
|
||||
["api", "sessions", id, "attachments", attachment, "raw" | "preview"]
|
||||
if !id.is_empty() && !attachment.is_empty() =>
|
||||
{
|
||||
Some(Popout::File)
|
||||
}
|
||||
["api", "sessions", id, "folders", folder, "files", name, ..]
|
||||
["api", "sessions", id, "folders", folder, "files", name, "raw" | "preview"]
|
||||
if !id.is_empty() && !folder.is_empty() && !name.is_empty() =>
|
||||
{
|
||||
Some(Popout::File)
|
||||
@@ -414,6 +426,38 @@ impl Default for OpenBudget {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a window does once its web content process has ended: a crash, or the
|
||||
/// system taking the process back under memory pressure. The window is blank
|
||||
/// from then on, since nothing reloads it by itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Recovery {
|
||||
/// Load the window's page again.
|
||||
Reload,
|
||||
/// Show the connect page: with the reason, when a server page was given up.
|
||||
ConnectPage { gave_up: bool },
|
||||
/// Close the pop-out.
|
||||
Close,
|
||||
/// Leave the window as it is.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// How a window recovers from the end of its web content process. `on_server`:
|
||||
/// a server page is the main window's document (pop-outs only exist then).
|
||||
/// `again`: the same window lost its process a moment ago already, so its page
|
||||
/// cannot stay up and loading it once more would only repeat that.
|
||||
pub fn recovery(is_main: bool, on_server: bool, again: bool) -> Recovery {
|
||||
match (is_main, on_server, again) {
|
||||
(_, true, false) => Recovery::Reload,
|
||||
(true, true, true) => Recovery::ConnectPage { gave_up: true },
|
||||
// The connect page itself, or a server load that had not committed.
|
||||
(true, false, false) => Recovery::ConnectPage { gave_up: false },
|
||||
(true, false, true) => Recovery::Nothing,
|
||||
(false, _, true) => Recovery::Close,
|
||||
// A pop-out with no server page behind it is on its way out anyway.
|
||||
(false, false, false) => Recovery::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Navigation {
|
||||
Allow,
|
||||
@@ -745,6 +789,26 @@ mod tests {
|
||||
normalize_server_url("box.tailf80371.ts.net").unwrap().as_str(),
|
||||
"https://box.tailf80371.ts.net/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_server_url("Box.Tailf80371.TS.NET/codeman").unwrap().as_str(),
|
||||
"https://box.tailf80371.ts.net/codeman/"
|
||||
);
|
||||
// With a port it is Codeman's own listener, not tailscale serve.
|
||||
assert_eq!(
|
||||
normalize_server_url("box.tailf80371.ts.net:3000").unwrap().as_str(),
|
||||
"http://box.tailf80371.ts.net:3000/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_server_url("box.tailf80371.ts.net:8443/x").unwrap().as_str(),
|
||||
"http://box.tailf80371.ts.net:8443/x/"
|
||||
);
|
||||
// A scheme that was typed is never second-guessed.
|
||||
assert_eq!(
|
||||
normalize_server_url("https://box.tailf80371.ts.net:8443")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://box.tailf80371.ts.net:8443/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_server_url("https://host/codeman").unwrap().as_str(),
|
||||
"https://host/codeman/"
|
||||
@@ -930,6 +994,7 @@ mod tests {
|
||||
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw",
|
||||
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview",
|
||||
"http://127.0.0.1:3000/api/download?path=x",
|
||||
"http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md",
|
||||
] {
|
||||
assert_eq!(script(s), NewWindow::AppWindow(u(s)), "{s}");
|
||||
assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}");
|
||||
@@ -1105,6 +1170,10 @@ mod tests {
|
||||
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview"
|
||||
));
|
||||
assert!(ok("http://127.0.0.1:3000/api/download?path=x"));
|
||||
// The path picker's preview, which its Open link shows in a window.
|
||||
assert!(ok(
|
||||
"http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md&sessionId=s1"
|
||||
));
|
||||
// A single decode is how the server's router sees the path.
|
||||
assert!(ok("http://127.0.0.1:3000/%73ession/abc"));
|
||||
|
||||
@@ -1137,7 +1206,18 @@ mod tests {
|
||||
assert!(!ok("http://127.0.0.1:3000/api/sessions//file-raw"));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/delete"));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/"));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png"));
|
||||
assert!(!ok(
|
||||
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/thumbnail"
|
||||
));
|
||||
assert!(!ok(
|
||||
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw/x"
|
||||
));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/download/x"));
|
||||
// The picker's other routes list and create folders: never a window.
|
||||
assert!(!ok("http://127.0.0.1:3000/api/filesystem/browse?path=%2F"));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/filesystem/mkdir"));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/filesystem/preview/x"));
|
||||
assert!(!ok("http://127.0.0.1:3000/api/status"));
|
||||
// Other origins and userinfo.
|
||||
assert!(!ok("http://127.0.0.1:3001/session/abc"));
|
||||
@@ -1210,6 +1290,27 @@ mod tests {
|
||||
assert!(!within_base(&u("https://host/grafana/"), &srv));
|
||||
assert!(!within_base(&u("https://host/"), &srv));
|
||||
assert!(!within_base(&u("https://host/codeman/../grafana/"), &srv));
|
||||
// The same way out, spelled so that only a decoder sees it.
|
||||
for escape in [
|
||||
"https://host/codeman/..%2Fgrafana/",
|
||||
"https://host/codeman/..%2fgrafana/x",
|
||||
"https://host/codeman/%2e%2e%2Fgrafana/",
|
||||
"https://host/codeman/..%5Cgrafana/",
|
||||
"https://host/codeman/a/%2E%2E%2F%2E%2E%2Fgrafana/",
|
||||
"https://host/codeman/session/a%zz",
|
||||
] {
|
||||
assert!(!within_base(&u(escape), &srv), "{escape}");
|
||||
assert!(!challenge_in_scope(&u(escape), &srv), "{escape}");
|
||||
}
|
||||
// Encoded characters that are part of a name stay inside.
|
||||
for inside in [
|
||||
"https://host/codeman/api/sessions/s1/folders/f1/files/50%25.txt/raw",
|
||||
"https://host/codeman/api/sessions/s1/file-raw?path=..%2Fx",
|
||||
"https://host/codeman/session/a%20b",
|
||||
"https://host/codeman/a..b/c",
|
||||
] {
|
||||
assert!(within_base(&u(inside), &srv), "{inside}");
|
||||
}
|
||||
assert!(!within_base(&u("http://host/codeman/"), &srv));
|
||||
assert!(!within_base(&u("https://other/codeman/"), &srv));
|
||||
let root = u("http://127.0.0.1:3000/");
|
||||
@@ -1274,6 +1375,24 @@ mod tests {
|
||||
assert!(!b.allow(later));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_recovers_from_a_lost_content_process_once() {
|
||||
// The first time, every window that shows a server page loads it again.
|
||||
assert_eq!(recovery(true, true, false), Recovery::Reload);
|
||||
assert_eq!(recovery(false, true, false), Recovery::Reload);
|
||||
// The connect page is shown anew, which also ends a load under way.
|
||||
assert_eq!(recovery(true, false, false), Recovery::ConnectPage { gave_up: false });
|
||||
// A second time in a row never reloads: that would loop.
|
||||
assert_eq!(recovery(true, true, true), Recovery::ConnectPage { gave_up: true });
|
||||
assert_eq!(recovery(false, true, true), Recovery::Close);
|
||||
assert_eq!(recovery(true, false, true), Recovery::Nothing);
|
||||
for is_main in [true, false] {
|
||||
for on_server in [true, false] {
|
||||
assert_ne!(recovery(is_main, on_server, true), Recovery::Reload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_auth() {
|
||||
assert_eq!(basic_auth_header("admin", "secret"), "Basic YWRtaW46c2VjcmV0");
|
||||
|
||||
@@ -228,6 +228,11 @@ fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
|
||||
|
||||
/// Ask the server. Blocking: call it from a worker thread.
|
||||
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
probe_within(server, authorization, TIMEOUT)
|
||||
}
|
||||
|
||||
/// `probe` with the time the whole exchange may take.
|
||||
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
@@ -236,7 +241,7 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.timeout_global(Some(timeout))
|
||||
.http_status_as_error(false)
|
||||
// Never follow: a 3xx comes back as an answer and is explained.
|
||||
.max_redirects(0)
|
||||
@@ -259,12 +264,16 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
};
|
||||
let location = header("location");
|
||||
let retry_after = header("retry-after");
|
||||
let body = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_string()
|
||||
.unwrap_or_default();
|
||||
let body = match resp.body_mut().with_config().limit(MAX_BODY).read_to_string() {
|
||||
Ok(body) => body,
|
||||
// The answer began but did not finish in time. Where the body
|
||||
// decides what answered (200, 403), that is a timeout, not
|
||||
// "something else answers here".
|
||||
Err(e) if matches!(status, 200 | 403) && transport_error(&e, false) == TransportError::Timeout => {
|
||||
return ProbeOutcome::Timeout;
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
classify_response(
|
||||
&status_url,
|
||||
status,
|
||||
@@ -724,4 +733,38 @@ mod tests {
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
|
||||
}
|
||||
|
||||
/// A Codeman that sends its status headers and then stalls (a blocked
|
||||
/// event loop, a lossy link) did not answer in time. It is not "something
|
||||
/// else on this port", which is what an empty body read as before.
|
||||
#[test]
|
||||
fn a_body_that_stalls_is_a_timeout() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Send `head` and the start of a body, then hold the connection open.
|
||||
fn serve_stalling(status_line: &'static str) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.read(&mut buf);
|
||||
let _ = s.write_all(
|
||||
format!("HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\n{{\"success\":true,").as_bytes(),
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
let short = Duration::from_millis(400);
|
||||
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("200 OK"))).unwrap();
|
||||
assert_eq!(probe_within(&url, None, short), ProbeOutcome::Timeout);
|
||||
|
||||
// A 401 has said all it needs to with its status line.
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("401 Unauthorized"))).unwrap();
|
||||
assert_eq!(probe_within(&url, None, short), ProbeOutcome::NeedsLogin);
|
||||
}
|
||||
}
|
||||
+144
-29
@@ -316,16 +316,17 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> {
|
||||
/// `src/index.ts web`), not one of its short-lived control commands.
|
||||
pub fn is_codeman_server(command: &str) -> bool {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let entry = words.iter().position(|w| {
|
||||
let is_entry = |w: &str| {
|
||||
let name = w.rsplit('/').next().unwrap_or(w);
|
||||
w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman"
|
||||
});
|
||||
match entry {
|
||||
Some(i) => {
|
||||
words.get(i + 1) == Some(&"web") && !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
// Any entry word that `web` follows: an earlier word may only look like
|
||||
// one (a folder named codeman in `tsx watch --cwd ~/codeman src/index.ts web`).
|
||||
(0..words.len()).any(|i| {
|
||||
is_entry(words[i])
|
||||
&& words.get(i + 1) == Some(&"web")
|
||||
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a command line is an install the app started on this Mac (the
|
||||
@@ -359,6 +360,16 @@ fn codeman_servers(procs: &[(u32, String)]) -> Vec<u32> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The install the app started on this Mac, if one runs, with the port it
|
||||
/// asked for as soon as one of its processes names it. The Terminal script
|
||||
/// that wraps the installer names none and is the older process, so the first
|
||||
/// match alone would report no port for the whole install.
|
||||
fn running_install(procs: &[(u32, String)]) -> Option<Option<u16>> {
|
||||
let mut installs = procs.iter().filter_map(|(_, c)| install_in_progress(c)).peekable();
|
||||
installs.peek()?;
|
||||
Some(installs.flatten().next())
|
||||
}
|
||||
|
||||
/// A listening TCP socket: its pid, address (`*`, `127.0.0.1`, `[::1]`, a
|
||||
/// specific IP) and port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -421,9 +432,8 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool {
|
||||
/// all-interfaces bind, else its own address.
|
||||
fn listener_url(l: &Listener) -> Option<Url> {
|
||||
let host = match l.host.as_str() {
|
||||
"*" | "0.0.0.0" | "127.0.0.1" => "127.0.0.1".to_string(),
|
||||
h if h.starts_with('[') => h.to_string(),
|
||||
h => h.to_string(),
|
||||
"*" | "0.0.0.0" => "127.0.0.1",
|
||||
h => h,
|
||||
};
|
||||
Url::parse(&format!("http://{host}:{}/", l.port)).ok()
|
||||
}
|
||||
@@ -436,6 +446,9 @@ pub enum LocalState {
|
||||
Running,
|
||||
/// Codeman answers but wants a login the app does not have.
|
||||
NeedsLogin,
|
||||
/// A Codeman server of this user runs but listens nowhere yet: it is
|
||||
/// still starting (the LaunchAgent at login, a restart).
|
||||
Starting,
|
||||
/// A Codeman server of this user runs, but not at an address the app can
|
||||
/// open (bound to one network address, say): see `elsewhere`.
|
||||
RunningElsewhere,
|
||||
@@ -475,6 +488,33 @@ pub struct LocalStatus {
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
/// Whether the probe got an answer from a Codeman server, logged in or not.
|
||||
fn answers(outcome: &ProbeOutcome) -> bool {
|
||||
matches!(
|
||||
outcome,
|
||||
ProbeOutcome::Ok { .. }
|
||||
| ProbeOutcome::PasswordChangeRequired
|
||||
| ProbeOutcome::NeedsLogin
|
||||
| ProbeOutcome::BadCredentials
|
||||
| ProbeOutcome::RateLimited { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// What the This Mac tab shows for what `inspect` found. `server_runs`: one of
|
||||
/// this user's Codeman server processes exists. `server_listens`: one of them
|
||||
/// has a listening socket. `installed`: Codeman is on this Mac.
|
||||
fn local_state(outcome: &ProbeOutcome, server_runs: bool, server_listens: bool, installed: bool) -> LocalState {
|
||||
match outcome {
|
||||
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => LocalState::Running,
|
||||
_ if answers(outcome) => LocalState::NeedsLogin,
|
||||
ProbeOutcome::Refused if server_runs && !server_listens => LocalState::Starting,
|
||||
ProbeOutcome::Refused if server_runs => LocalState::RunningElsewhere,
|
||||
ProbeOutcome::Refused if installed => LocalState::Stopped,
|
||||
ProbeOutcome::Refused => LocalState::NotInstalled,
|
||||
_ => LocalState::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// How hard `inspect` looks for an installed Codeman when nothing answers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Depth {
|
||||
@@ -494,7 +534,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
let procs = list_processes();
|
||||
let servers = codeman_servers(&procs);
|
||||
let found = listeners(&servers);
|
||||
let install = procs.iter().find_map(|(_, c)| install_in_progress(c));
|
||||
let install = running_install(&procs);
|
||||
|
||||
// The login goes only to this user's own Codeman on that port.
|
||||
let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) {
|
||||
@@ -542,20 +582,13 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
|
||||
let app = home.join(".codeman/app");
|
||||
let install_complete = app.join(".install-complete").is_file();
|
||||
let answering = match &outcome {
|
||||
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => Some(LocalState::Running),
|
||||
ProbeOutcome::NeedsLogin | ProbeOutcome::BadCredentials | ProbeOutcome::RateLimited { .. } => {
|
||||
Some(LocalState::NeedsLogin)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Something else on Codeman's port (a dev server on 3000, say) says
|
||||
// nothing about whether Codeman is installed, so look either way.
|
||||
let on_disk = install_complete
|
||||
|| app.join("dist/index.js").is_file()
|
||||
|| agent_path(&home).is_file()
|
||||
|| daemon_path().is_file();
|
||||
let installed = answering.is_some()
|
||||
let installed = answers(&outcome)
|
||||
|| !servers.is_empty()
|
||||
|| on_disk
|
||||
|| match depth {
|
||||
@@ -563,13 +596,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
Depth::Login => local_server::installed(false),
|
||||
Depth::Interactive => local_server::installed(true),
|
||||
};
|
||||
let state = match (answering, &outcome) {
|
||||
(Some(state), _) => state,
|
||||
(None, ProbeOutcome::Refused) if !servers.is_empty() => LocalState::RunningElsewhere,
|
||||
(None, ProbeOutcome::Refused) if installed => LocalState::Stopped,
|
||||
(None, ProbeOutcome::Refused) => LocalState::NotInstalled,
|
||||
(None, _) => LocalState::Other,
|
||||
};
|
||||
let state = local_state(&outcome, !servers.is_empty(), !found.is_empty(), installed);
|
||||
if state != LocalState::RunningElsewhere {
|
||||
elsewhere.clear();
|
||||
}
|
||||
@@ -612,6 +639,15 @@ fn already_running() -> Option<String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether `url` is where the launchd job `service` answers: its scheme and
|
||||
/// port on this Mac, under `localhost` as much as 127.0.0.1. The path is left
|
||||
/// out: a job with a base path answers below `/` too, and a second server on
|
||||
/// its port would run without the job's settings.
|
||||
fn served_by(service: &ServiceConfig, url: &Url) -> bool {
|
||||
let at = local_url(Some(service));
|
||||
policy::is_loopback(url) && url.scheme() == at.scheme() && url.port_or_known_default() == at.port_or_known_default()
|
||||
}
|
||||
|
||||
/// Start the Codeman on this Mac for `url`. A LaunchAgent that serves that
|
||||
/// address is started through launchd, so it runs with its own settings
|
||||
/// (host, password) and stays supervised; anything else gets `codeman web -d`
|
||||
@@ -621,7 +657,7 @@ pub fn start_local(url: &Url) -> Result<String, String> {
|
||||
return Err(why);
|
||||
}
|
||||
match read_service() {
|
||||
Some(s) if local_url(Some(&s)) == *url => {
|
||||
Some(s) if served_by(&s, url) => {
|
||||
if s.daemon {
|
||||
return Err(format!(
|
||||
"A system LaunchDaemon runs Codeman on this Mac; an administrator starts it: sudo launchctl kickstart -k system/{SERVICE_LABEL}"
|
||||
@@ -677,7 +713,7 @@ fn start_launch_agent(port: u16) -> Result<String, String> {
|
||||
if !port_free(port) {
|
||||
return Ok(format!("launchd started Codeman ({SERVICE_LABEL})."));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err("launchd started Codeman, but it did not answer within 30 seconds. Its log is /tmp/codeman.log for an install.sh setup.".into())
|
||||
}
|
||||
@@ -1218,6 +1254,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_goes_to_the_launchd_job_that_serves_the_address() {
|
||||
let job = parse_service(&json!({
|
||||
"ProgramArguments": ["node", "i.js", "web"],
|
||||
"EnvironmentVariables": { "CODEMAN_PORT": "3001", "CODEMAN_BASE_URL": "/codeman" }
|
||||
}));
|
||||
// The app's own address for it, and what a user types for the same server.
|
||||
assert!(served_by(&job, &u("http://127.0.0.1:3001/codeman/")));
|
||||
assert!(served_by(&job, &u("http://localhost:3001/codeman/")));
|
||||
assert!(served_by(&job, &u("http://LOCALHOST:3001/")));
|
||||
// Another port, scheme or machine is not this job's server.
|
||||
assert!(!served_by(&job, &u("http://127.0.0.1:3000/codeman/")));
|
||||
assert!(!served_by(&job, &u("https://127.0.0.1:3001/codeman/")));
|
||||
assert!(!served_by(&job, &u("http://192.168.1.5:3001/codeman/")));
|
||||
assert!(!served_by(&job, &u("http://localhost.example.com:3001/")));
|
||||
|
||||
let https = parse_service(&json!({ "ProgramArguments": ["node", "i.js", "web", "--https"] }));
|
||||
assert!(served_by(&https, &u("https://localhost:3000/")));
|
||||
assert!(!served_by(&https, &u("http://localhost:3000/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_mac_state_for_what_was_found() {
|
||||
let ok = ProbeOutcome::Ok {
|
||||
version: "1.48.0".into(),
|
||||
};
|
||||
// Whatever else is true, a Codeman that answers is what the tab shows.
|
||||
for (runs, listens, installed) in [(false, false, false), (true, true, true), (true, false, false)] {
|
||||
assert_eq!(local_state(&ok, runs, listens, installed), LocalState::Running);
|
||||
assert_eq!(
|
||||
local_state(&ProbeOutcome::PasswordChangeRequired, runs, listens, installed),
|
||||
LocalState::Running
|
||||
);
|
||||
for login in [
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::BadCredentials,
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None,
|
||||
},
|
||||
] {
|
||||
assert_eq!(local_state(&login, runs, listens, installed), LocalState::NeedsLogin);
|
||||
}
|
||||
}
|
||||
// Nothing answers. A server process with no socket yet is starting,
|
||||
// which comes before "runs somewhere else": the app must not send the
|
||||
// user to another address for a server that is two seconds from up.
|
||||
let refused = ProbeOutcome::Refused;
|
||||
assert_eq!(local_state(&refused, true, false, true), LocalState::Starting);
|
||||
assert_eq!(local_state(&refused, true, false, false), LocalState::Starting);
|
||||
assert_eq!(local_state(&refused, true, true, true), LocalState::RunningElsewhere);
|
||||
assert_eq!(local_state(&refused, false, false, true), LocalState::Stopped);
|
||||
assert_eq!(local_state(&refused, false, false, false), LocalState::NotInstalled);
|
||||
// Something else holds the port, or the look itself failed.
|
||||
for other in [
|
||||
ProbeOutcome::NotCodeman { status: 404 },
|
||||
ProbeOutcome::Timeout,
|
||||
ProbeOutcome::MaybeHttps,
|
||||
] {
|
||||
assert_eq!(local_state(&other, true, true, true), LocalState::Other);
|
||||
assert_eq!(local_state(&other, false, false, false), LocalState::Other);
|
||||
}
|
||||
assert!(answers(&ProbeOutcome::BadCredentials) && !answers(&refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processes_and_listeners() {
|
||||
let ps = " 101 /opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web\n\
|
||||
@@ -1231,6 +1332,15 @@ mod tests {
|
||||
909 bash /var/folders/x/T/codeman-install.Ab12Cd --local --service --port 3001\n\
|
||||
1001 /bin/bash /var/folders/x/T/install-codeman-remote-55-1.command\n\
|
||||
garbage line\n";
|
||||
// A folder named like the command comes before the real entry point.
|
||||
assert!(is_codeman_server(
|
||||
"node /x/tsx/cli.mjs watch --cwd /Users/k/codeman src/index.ts web"
|
||||
));
|
||||
assert!(is_codeman_server(
|
||||
"node --watch-path /Users/k/codeman /Users/k/codeman/src/index.ts web --port 3001"
|
||||
));
|
||||
assert!(!is_codeman_server("node /Users/k/codeman/scripts/build.mjs web"));
|
||||
assert!(!is_codeman_server("/opt/homebrew/bin/codeman attach web"));
|
||||
let procs = parse_ps(ps);
|
||||
assert_eq!(
|
||||
procs[0],
|
||||
@@ -1242,6 +1352,11 @@ mod tests {
|
||||
assert_eq!(codeman_servers(&procs), [101, 202, 303]);
|
||||
let installs: Vec<Option<u16>> = procs.iter().filter_map(|(_, c)| install_in_progress(c)).collect();
|
||||
assert_eq!(installs, [None, Some(3001)]);
|
||||
// The Terminal script comes first and names no port: the installer's wins.
|
||||
assert_eq!(running_install(&procs), Some(Some(3001)));
|
||||
// Before the installer is downloaded, only the script runs.
|
||||
assert_eq!(running_install(&procs[..8]), Some(None));
|
||||
assert_eq!(running_install(&procs[..7]), None);
|
||||
|
||||
let lsof = "p101\nf21\nn127.0.0.1:3000\nf22\nn172.17.0.1:3999\np202\nf9\nn*:4000\np303\nn[::1]:5000\nnbogus\n";
|
||||
let found = parse_lsof(lsof);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user