Compare commits

...
Author SHA1 Message Date
Codeman maintainer ea23445ad7 ci(desktop): a rebuild by hand takes only a Codeman release tag on master
A dispatch with -f tag=<any tag> built that tag, and sign then ran the tag's
sign-macos.sh with the Developer ID key unlocked and the notary login in its
environment. Nothing checked the tag: xterm-zerolag-input@0.1.7 would have put
Codeman.dmg on that package's release, and a tag on a commit that never
reached master would have run unreviewed scripts with the secrets.

build and sign now each check their own checkout before anything else runs:
the tag must read codeman@x.y.z (or aicodeman@x.y.z, which a failed rename
leaves) and GitHub's compare of master with the checked-out commit must say
behind or identical. The check is inline, since a script from the checkout
would be the tag's own code. Release's own call (a push, not a dispatch) and
the branch dry run are unchanged.

The header says why a master-only Environment would not close this path, and
release.yml's macos-app job now says that any permission a called job asks
for must be granted there too, or Release fails at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:02:45 +01:00
Codeman maintainer d5273347c3 ci(desktop): build, sign, notarize and attach Codeman.dmg on every release
Every Codeman release now carries the Mac app as Codeman.dmg and
Codeman.dmg.sha256, under a name that never changes, so
https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always
serves the newest app.

.github/workflows/desktop-dmg.yml (workflow_call from Release with
tag codeman@<version>; workflow_dispatch with an optional tag) has three jobs,
so no job that holds a secret runs code from a crate:
- build (macos-26, contents: read, no secret, no cache) runs cargo test --locked
  and bundle-macos.sh --universal ad hoc, with CODEMAN_DESKTOP_INSTALLER_URL,
  CODEMAN_DESKTOP_INSTALL_BRANCH and SIGN_IDENTITY unset, checks both slices,
  the signature and that no test installer URL or branch is baked in, and
  uploads the app as a ditto zip.
- sign (macos-26, contents: read) refuses an artifact with an entry outside
  Codeman.app, a ".." or a symlink, imports MACOS_CERT_P12 into a keychain under
  RUNNER_TEMP with a random password, picks the one Developer ID Application
  identity by its hash and checks it belongs to APPLE_TEAM_ID, runs sign-macos.sh
  with the Apple ID login and uploads the signed pair as the codeman-dmg
  artifact. Its last step deletes the keychain and the decoded .p12 whatever
  happened before.
- attach (ubuntu, contents: write, no secret) runs only with a tag, on master
  (Release itself, or a rebuild started there) and in Ark0N/Codeman; it uploads
  the pair with --clobber and checks that the release's download link, and the
  Latest one when this release holds Latest, serve those bytes.
A run without a tag is the dry run: everything but attach.

release.yml reads the Codeman version from changesets' publishedPackages
(published alone is also true for an xterm-zerolag-input-only release) and runs
the macOS app job only for a Codeman release, passing it the five secrets by
name, never secrets: inherit. A new step puts Latest back on the newest
codeman@ release after every publish, so a release that moved only
xterm-zerolag-input no longer takes the badge and breaks the stable link. Both
new steps sit after the retag and only warn on failure, so npm, the tag and the
MCP publish are never held up by them.

desktop-ci.yml checks pull requests and master pushes that touch
packages/desktop: cargo fmt --check, clippy -D warnings, cargo test --locked and
bash -n under macOS's bash 3.2, on macos-26 with a read-only token. Both
workflows pin Rust 1.99.1, what the macos-26 image and the Mac mini ran on
2026-11-08.

docs/releasing.md: step 6 holds a site change that links the app until the DMG
check passes, step 7 names the new job, and a new section covers the secrets,
the check, a dry run, a rebuild and what to do when notarization fails.

actionlint 1.7.12 (with shellcheck 0.11.0) reports nothing on the three
workflows, and master's test/release-notes.test.ts assertions hold on the new
release.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:03:25 +01:00
Codeman maintainer 94912241bc feat(desktop): sign-macos.sh signs, notarizes and staples the app and its DMG
The release DMG is signed with the owner's Developer ID and notarized, so a
downloaded copy opens after macOS's usual confirmation instead of stopping at
Gatekeeper. The work is a script of its own, so CI and a Mac run it the same way:

  SIGN_IDENTITY=... NOTARY_PROFILE=<profile> \
    packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>

It re-signs the app with the hardened runtime, a secure timestamp and
macos/entitlements.plist, checks the signature is a Developer ID one with the
runtime flag, has notarytool notarize it and staples the ticket, builds the DMG
around the stapled app, signs it, notarizes and staples it too, and has spctl
assess both. Codeman.dmg and Codeman.dmg.sha256 reach <out-dir> only after every
check passed; a failed run removes an older pair there rather than leave one to
upload by mistake. A submission Apple does not accept stops the script with its
id and Apple's log. The notary login is either a notarytool keychain profile
(NOTARY_PROFILE, NOTARY_KEYCHAIN) or APPLE_ID, APPLE_APP_PASSWORD and
APPLE_TEAM_ID; `sign-macos.sh --check` tests that without doing anything.

The DMG layout (the app next to an /Applications link, UDZO, volume "Codeman")
moves to make-dmg.sh, which both scripts call, and gains a retry: hosted macOS
runners now and then fail hdiutil create with "Resource busy". bundle-macos.sh
keeps its SIGN_IDENTITY path as it was; its new --notarize checks the
environment before building, then hands the app to sign-macos.sh.
packages/desktop/README.md says the release DMG is notarized and how to do
the same by hand, and drops the CI build and notarization from its ideas list.

All three scripts are bash 3.2 clean, and shellcheck reports nothing. With the
macOS tools stubbed, sign-macos.sh was run under bash 3.2 through a full
signing, a notarization rejected for the app and for the DMG, two hdiutil
failures then a pass, three failures, a relative out-dir and every missing
setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:57:29 +01:00
Codeman maintainer 1e262b04d1 chore(release): take release.yml and docs/releasing.md from master as they are
This branch starts from feat/desktop-app (#763), which predates master's MCP
registry job and the release-notes body in release.yml and their pages in
docs/releasing.md. Both files are master's text verbatim here, so the Mac app
commit after this one changes them by additions only, and a rebase onto master
drops this commit as empty.

release.yml now names .github/workflows/publish-mcp.yml and
scripts/generate-release-notes.mjs, which exist on master but not on this
branch. The Release workflow runs only on pushes to master, so that matters
nowhere else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:57:11 +01:00
Codeman maintainer 92797c2619 test(desktop): unit tests for main.rs's decisions and a browser test for the connect page
main.rs was the only source file without tests, and it held three decisions
with real failure modes:

- Which login a connect goes out with. The choice was a closure body inside
  the probe worker; it is now choose_login, tested for every row: typed wins,
  then the Keychain, then the local Codeman's own login, which is used only
  when no saved password was expected and only for the same username.
- What the Keychain keeps afterwards (update_keychain): saved or deleted by
  Remember, never a copy of the local server's login, never a password the
  server wants changed, and the old user's item deleted before the new save.
  The in-memory Keychain store moves out of credentials' test module so other
  test modules can use it.
- When a commit in the main window means the server page is loaded.

The connect page had only substring checks in the repository; the checks that
drove it lived in a scratch script. They are now
test/desktop-connect-page.browser.test.ts: Chromium, the page's real CSP, a
stubbed native side, five ordered walks through every state of both tabs
(first run, logins, failures, redirects, Start, the install wait, the SSH
install, layout, double clicks, keys, a starting server, a WebKit too old).
Listed in BROWSER_TEST_GLOBS, so the nightly browser suite runs it and
`npm test` does not.

Also pins the install one-liner the page shows to the one Copy copies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:54:49 +01:00
Codeman maintainer a42ce9c79f fix(desktop): a starting server, windows that open where expected, and the review's smaller findings
Found by the three-part review of this branch. Each was checked against the
source first; every decision that can run without a window has a unit test.

This Mac:
- A Codeman that runs but listens nowhere yet (the first seconds after login,
  a restart) was reported as "already running ... it listens at process N,
  which this app cannot open", and Start was refused for it. It is now its own
  state: the tab says "starting", looks again every second, and opens Codeman
  once it is up when a connect to this Mac had just been refused. The state
  table is a pure function with a test (setup::local_state).
- A server the app starts runs in the home folder, like one started by launchd
  or in a terminal. It ran in the app's own directory (`/` after a Finder
  launch), which Codeman then used as the default for sessions.
- Server detection accepts any entry word that `web` follows, so a dev server
  under a folder named codeman is seen and never gets a second server beside
  it.
- A login shell is polled from 5 ms up instead of every 100 ms, and the port
  after a launchd start every 100 ms instead of 500: both were pure waiting.
- The last line of stdout is no longer glued to the first line of stderr.

Addresses and policy:
- A tailnet name typed with a port gets http://, not https://. With a port it
  is Codeman's own listener; https there ended in a TLS error with no hint.
- within_base refuses `..%2F` and the other encoded ways out of the base path,
  so a sibling app behind a decoding proxy never sees the login.
- A status answer whose body stalls is a timeout, not "something else answers
  here".
- The folder pop-out pattern matches the two routes the server has.
- A malformed `window` in desktop.json no longer costs the server list.

Windows:
- Pop-outs step off the window in use. tao centres a window with no position,
  so every pop-out opened exactly on top of the one before.
- A window opened by a script no longer brings the app to the front: wry
  activated the application for it, whatever the window itself was told.
- The first-run window sits between the menu bar and the Dock. Centred in the
  whole display, its bottom was under the Dock on a 900 pt high one.
- The window frame is saved once after a move or resize ends, not once a
  second during the drag.

Also makes the connect page independent of TextEncoder and scrollIntoView, so
it runs in jsdom as well as in a web view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:40:53 +01:00
Codeman maintainer 116db3ba66 fix(desktop): a connect page that holds still, answers a press and never strands the user
Layout:
- The header no longer moves. The page was centred with align-items, so every
  change of the card's height moved everything (up to 150px between states),
  and a page taller than its window lost its top above the scroll origin,
  where no scrolling reaches it. The top margin is now fixed, the card grows
  downward and has a minimum height, and the page scrolls from its top. A
  short window gets a compact header.
- The status line sits right under the action it reports on, above the
  "not installed there yet?" section, takes no room while empty, and is
  scrolled into view in a small window. It was below the fold there.
- Looking at this Mac again keeps what was found on screen (the pane used to
  collapse to "Looking..." and grow back).

Feedback and keys:
- Buttons answer hover and press, every control shows keyboard focus, the
  primary button and the footer meet WCAG AA contrast, "Opening Codeman" keeps
  its spinner while the server page loads.
- Return opens or starts the Codeman on this Mac, Esc cancels a connect.
  Install always takes a click.
- The This Mac result is a live region, Forget buttons are named after their
  server, a long unbroken token wraps inside the card, the status text can be
  selected.

Correctness:
- The second click of a double click acts only on the control that took the
  first. It could connect to the server that slid under the pointer after a
  Forget, or flip "Connect automatically".
- An address, username or password too long for the app is refused with a
  reason. The app drops such a message without an answer, which left the page
  on "Connecting..." (the limits are pinned to policy.rs by a test).
- A connect answer goes to the tab that asked, a failed SSH install to its
  tab, and the login block no longer claims a saved password for another
  server.
- An automatic connect to the Codeman on this Mac no longer looks at this Mac
  first; a failed connect, Cancel or an error asks for that look.
- A login over plain http:// to another machine says that it is unencrypted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:26:57 +01:00
Codeman maintainer 01d3379b74 fix(desktop): recover a crashed page, name every dialog's frame, and four smaller review fixes
The findings of the 2026-11-05 review of this branch, each with a unit test
where the code can be tested without a window:

- A window whose web content process ended (a WebKit crash, or macOS taking
  the process back under memory pressure) stayed blank: wry answers WebKit's
  notice itself, which turns WebKit's own reload off. The window now loads its
  page again. A second end within 30 seconds sends the main window back to the
  connect page and closes a pop-out, so it cannot loop (policy::recovery).
- A JavaScript dialog from a frame nothing names (about:srcdoc, a data: or
  blob: URL under an opaque origin) showed the page's text as its bold first
  line. The first line is now always the app's own, the "embedded" flag is set
  when the origin names no host (it was the wrong way round), and every
  sub-frame counts as embedded, a trusted web tab on Codeman's origin included.
- The connect page lost the text of an error that needs the login fields: the
  tab switch cleared the status it had just set.
- Start for http://localhost:PORT missed the LaunchAgent (the compare was
  exact against 127.0.0.1) and ran a second, unsupervised server on its port.
- The path picker's preview Open link (api/filesystem/preview) did nothing: it
  was the one page Codeman opens in a window that the allowlist lacked.
- The port of a running install was lost whenever the Terminal script that
  wraps the installer came first in ps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:20:01 +01:00
20 changed files with 3731 additions and 228 deletions

No files matched your search

+72
View File
@@ -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
+399
View File
@@ -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
+90 -2
View File
@@ -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 }}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+205 -55
View File
@@ -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>&#8963;&#8984;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);
+25 -7
View File
@@ -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
+42
View File
@@ -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"
+144
View File
@@ -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")"
+22
View File
@@ -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);
+102 -2
View File
@@ -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());
+21 -11
View File
@@ -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!(
+43 -1
View File
@@ -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());
+30 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+50 -7
View File
@@ -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
View File
@@ -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