diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 45da981f..036ad081 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,52 +1,145 @@
name: Release
-# Builds the downloadable Insomnia package and, for a version tag, publishes
-# it as a GitHub Release. docs/releasing.md walks through a release.
+# Builds the downloadable Insomnia package and publishes it on one of two
+# channels. docs/releasing.md walks through both.
#
# Triggers
-# push of a tag v test, build, package, attest, publish a GitHub
-# Release. must equal
-# CFBundleShortVersionString in Resources/Info.plist.
+# push of a tag v stable: test, build, package, attest, publish a
+# GitHub Release marked Latest. must
+# equal CFBundleShortVersionString in
+# Resources/Info.plist.
+# schedule, daily nightly: the same for the head of main, published
+# as a prerelease that is never Latest, under a new
+# tag nightly--. A commit that already has a nightly
+# is skipped before anything is built.
# workflow_dispatch test, build and package only; the zip and
# SHA256SUMS are uploaded as a workflow artifact.
-# Nothing is attested or published.
+# Nothing is attested or published. With the
+# nightly input set, it is a nightly run instead,
+# and only on main.
#
# The bundle is ad-hoc signed and not notarized (build-app.sh), so the run
-# uses no secrets and no keychain. The release is a prerelease. Every action
-# is pinned to a commit.
+# uses no secrets and no keychain. Every action is pinned to a commit.
on:
push:
tags: ['v*']
+ schedule:
+ # 07:17 UTC. GitHub delays scheduled runs most at the start of the hour.
+ - cron: '17 7 * * *'
workflow_dispatch:
+ inputs:
+ nightly:
+ description: Publish a nightly prerelease of this commit (main only; skipped if it already has one)
+ type: boolean
+ default: false
# Nothing at the top level; each job declares exactly what it needs.
permissions: {}
-# One run per tag; a rerun waits instead of cancelling a publish in progress.
+# One run per ref; a rerun waits instead of cancelling a publish in progress.
+# Scheduled and manual runs on main share a group, so two nightlies of one
+# commit never race.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
+ plan:
+ name: Choose the channel
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ # Reads tags and releases to skip a commit that already has a nightly.
+ permissions:
+ contents: read
+ outputs:
+ channel: ${{ steps.plan.outputs.channel }}
+ build: ${{ steps.plan.outputs.build }}
+ tag: ${{ steps.plan.outputs.tag }}
+ steps:
+ - name: Choose the channel, and the tag for a nightly
+ id: plan
+ env:
+ GH_TOKEN: ${{ github.token }}
+ EVENT: ${{ github.event_name }}
+ NIGHTLY_INPUT: ${{ inputs.nightly }}
+ run: |
+ case "$EVENT" in
+ push)
+ # The trigger matches v* tags only.
+ printf 'channel=stable\nbuild=true\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ ;;
+ workflow_dispatch)
+ if [[ "$NIGHTLY_INPUT" != true ]]; then
+ printf 'channel=artifact\nbuild=true\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ ;;
+ schedule) ;;
+ *)
+ echo "unexpected event $EVENT" >&2
+ exit 1
+ ;;
+ esac
+ if [[ "$GITHUB_REF" != refs/heads/main ]]; then
+ echo "A nightly is built from main only, not $GITHUB_REF. Nothing was built or published." >&2
+ exit 1
+ fi
+ sha12="${GITHUB_SHA:0:12}"
+ # Nightly tags of this commit. The workflow makes lightweight
+ # tags, so each ref names the commit itself.
+ refs="$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/nightly-" --jq '.[] | "\(.ref) \(.object.sha)"')"
+ releases="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[].tag_name')"
+ while read -r ref sha; do
+ [[ "$ref" =~ ^refs/tags/nightly-[0-9]{8}-${sha12}$ ]] || continue
+ tag="${ref#refs/tags/}"
+ if [[ "$sha" != "$GITHUB_SHA" ]]; then
+ echo "tag $tag points at $sha, not $GITHUB_SHA. Nothing was built or published." >&2
+ exit 1
+ fi
+ if grep -qxF "$tag" <<< "$releases"; then
+ echo "$GITHUB_SHA already has the nightly $tag. Nothing to build."
+ printf 'channel=nightly\nbuild=false\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "tag $tag exists but has no release. Rerun the failed jobs of the run that made it, or delete the tag; nothing was built or published." >&2
+ exit 1
+ done <<< "$refs"
+ tag="nightly-$(date -u +%Y%m%d)-$sha12"
+ echo "nightly $tag for $GITHUB_SHA"
+ printf 'channel=nightly\nbuild=true\ntag=%s\n' "$tag" >> "$GITHUB_OUTPUT"
+
build:
name: Test, build and package (macOS 26)
+ needs: plan
+ if: needs.plan.outputs.build == 'true'
runs-on: macos-26
timeout-minutes: 60
- # Read-only: only the release job below may write.
+ # Read-only: only the publishing jobs below may write.
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
+ package: ${{ steps.version.outputs.package }}
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - name: Read the version and check the tag against it
+ - name: Read the version, check the tag against it and name the package
id: version
+ env:
+ CHANNEL: ${{ needs.plan.outputs.channel }}
+ NIGHTLY_TAG: ${{ needs.plan.outputs.tag }}
run: |
+ head="$(git rev-parse HEAD)"
+ if [[ "$head" != "$GITHUB_SHA" ]]; then
+ echo "checked out $head, not $GITHUB_SHA" >&2
+ exit 1
+ fi
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)"
case "$GITHUB_REF" in
refs/tags/v*)
@@ -57,8 +150,18 @@ jobs:
fi
;;
esac
+ package="Insomnia-$version-macos"
+ if [[ "$CHANNEL" == nightly ]]; then
+ want="^nightly-[0-9]{8}-${GITHUB_SHA:0:12}\$"
+ if [[ ! "$NIGHTLY_TAG" =~ $want ]]; then
+ echo "nightly tag '$NIGHTLY_TAG' does not name $GITHUB_SHA" >&2
+ exit 1
+ fi
+ package="Insomnia-$version-$NIGHTLY_TAG-macos"
+ fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- echo "Insomnia $version"
+ echo "package=$package" >> "$GITHUB_OUTPUT"
+ echo "Insomnia $version, $CHANNEL, package $package"
- name: Print toolchain versions
run: |
@@ -98,9 +201,9 @@ jobs:
- name: Package
env:
- VERSION: ${{ steps.version.outputs.version }}
+ PACKAGE: ${{ steps.version.outputs.package }}
run: |
- pkg="Insomnia-$VERSION-macos"
+ pkg="$PACKAGE"
mkdir -p "release/$pkg"
/usr/bin/ditto dist/Insomnia.app "release/$pkg/Insomnia.app"
cp scripts/install.sh scripts/uninstall.sh "release/$pkg/"
@@ -113,14 +216,14 @@ jobs:
- name: Upload the package as a workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: Insomnia-${{ steps.version.outputs.version }}-macos
+ name: ${{ steps.version.outputs.package }}
path: |
release/*.zip
release/SHA256SUMS
if-no-files-found: error
- release:
- name: Attest and publish the GitHub Release
+ stable:
+ name: Attest and publish the stable release
needs: build
# Pushed tags only. A manual run stops at the artifact, even one started
# on a tag ref, which a check of the ref alone would let through.
@@ -137,7 +240,7 @@ jobs:
- name: Download the package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
- name: Insomnia-${{ needs.build.outputs.version }}-macos
+ name: ${{ needs.build.outputs.package }}
path: release
- name: Attest build provenance
@@ -149,7 +252,7 @@ jobs:
run: |
zip="$(basename release/*.zip)"
sum="$(cut -d' ' -f1 release/SHA256SUMS)"
- signing="Prerelease: ad-hoc signed and not notarized, so macOS blocks the first launch until you allow it in System Settings > Privacy & Security. The checksum and attestation below show this zip is what the Release workflow built from the tagged commit."
+ signing="Stable release. Ad-hoc signed and not notarized, so macOS blocks the first launch until you allow it in System Settings > Privacy & Security. The checksum and attestation below show this zip is what the Release workflow built from the tagged commit."
install_note="The installer checks the bundle's signature, identifier and version before asking for your password, but it cannot verify where this bundle came from: that is what the checksum and the attestation above are for, and \`--allow-unverified-origin\` says you checked them. It then installs the app, the recovery agent and the sudoers rule described in the README."
{
echo "Insomnia $VERSION for Apple Silicon Macs with macOS 26 or later. The app is built for arm64 only, so the installer stops on an Intel Mac."
@@ -160,10 +263,10 @@ jobs:
echo
echo '```bash'
echo "shasum -a 256 -c SHA256SUMS"
- echo "gh attestation verify $zip -R $GITHUB_REPOSITORY --signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF"
+ echo "gh attestation verify $zip -R $GITHUB_REPOSITORY --signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF --source-digest $GITHUB_SHA"
echo '```'
echo
- echo "The second command checks that this zip is the subject of an attestation signed by this repository's Release workflow for the tag \`$GITHUB_REF_NAME\`, not by just any workflow of this repository."
+ echo "The second command checks that this zip is the subject of an attestation signed by this repository's Release workflow for the tag \`$GITHUB_REF_NAME\` at commit \`$GITHUB_SHA\`, not by just any workflow of this repository, and not for a nightly build of main."
echo
echo "SHA-256 of \`$zip\`: \`$sum\`"
echo
@@ -188,7 +291,104 @@ jobs:
run: |
gh release create "$GITHUB_REF_NAME" release/*.zip release/SHA256SUMS \
--repo "$GITHUB_REPOSITORY" \
- --title "Insomnia $VERSION" \
+ --title "Insomnia $VERSION (stable)" \
+ --notes-file notes.md \
+ --verify-tag \
+ --latest
+
+ nightly:
+ name: Attest and publish the nightly prerelease
+ needs: [plan, build]
+ # Scheduled runs, and manual runs with the nightly input, on main only;
+ # the plan job has already refused any other ref.
+ if: needs.plan.outputs.channel == 'nightly' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: write # create the tag and the release, upload its assets
+ id-token: write # sign the provenance statement (Sigstore)
+ attestations: write # store the attestation on the repository
+ env:
+ VERSION: ${{ needs.build.outputs.version }}
+ TAG: ${{ needs.plan.outputs.tag }}
+ steps:
+ - name: Download the package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ needs.build.outputs.package }}
+ path: release
+
+ - name: Attest build provenance
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
+ with:
+ subject-path: release/*.zip
+
+ - name: Write the release notes
+ run: |
+ zip="$(basename release/*.zip)"
+ sum="$(cut -d' ' -f1 release/SHA256SUMS)"
+ channel="Nightly prerelease: an automatic build of \`main\` at \`$GITHUB_SHA\`, for trying the newest changes. Nobody chose this commit as a release, it may be broken, and it is never marked Latest. The latest stable release is at $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/latest."
+ version_note="The app reports version $VERSION, the version in Resources/Info.plist at that commit, so the app itself does not tell nightlies apart. The tag \`$TAG\` and the zip's name identify this build."
+ signing="Ad-hoc signed and not notarized, so macOS blocks the first launch until you allow it in System Settings > Privacy & Security. The checksum and attestation below show this zip is what the Release workflow built from that commit of main."
+ install_note="The installer checks the bundle's signature, identifier and version before asking for your password, but it cannot verify where this bundle came from: that is what the checksum and the attestation above are for, and \`--allow-unverified-origin\` says you checked them. It then installs the app, the recovery agent and the sudoers rule described in the README."
+ {
+ echo "Insomnia $VERSION for Apple Silicon Macs with macOS 26 or later. The app is built for arm64 only, so the installer stops on an Intel Mac."
+ echo
+ echo "$channel"
+ echo
+ echo "$version_note"
+ echo
+ echo "$signing"
+ echo
+ echo "## Verify the download"
+ echo
+ echo '```bash'
+ echo "shasum -a 256 -c SHA256SUMS"
+ echo "gh attestation verify $zip -R $GITHUB_REPOSITORY --signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF --source-digest $GITHUB_SHA"
+ echo '```'
+ echo
+ echo "The second command checks that this zip is the subject of an attestation signed by this repository's Release workflow for \`$GITHUB_REF\` at commit \`$GITHUB_SHA\`, not by just any workflow of this repository, and not for a stable tag."
+ echo
+ echo "SHA-256 of \`$zip\`: \`$sum\`"
+ echo
+ echo "## Install"
+ echo
+ echo '```bash'
+ echo "ditto -x -k $zip ."
+ echo "cd ${zip%.zip}"
+ echo "./install.sh --allow-unverified-origin --app ./Insomnia.app"
+ echo "open \"\$HOME/Applications/Insomnia.app\""
+ echo '```'
+ echo
+ echo "$install_note"
+ echo
+ echo "Built from \`$GITHUB_SHA\` by run $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID."
+ } > notes.md
+ cat notes.md
+
+ - name: Create the tag and the prerelease
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # Creating a ref fails when it exists, so two runs never share a
+ # tag and an existing one is never moved. A rerun of this job after
+ # a failed release step finds the tag it made on this commit.
+ if ! gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$GITHUB_SHA" --silent; then
+ at="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq '.object.sha')"
+ if [[ "$at" != "$GITHUB_SHA" ]]; then
+ echo "tag $TAG points at $at, not $GITHUB_SHA. Nothing was published." >&2
+ exit 1
+ fi
+ echo "tag $TAG already points at $GITHUB_SHA"
+ fi
+ day="${TAG#nightly-}"
+ day="${day%%-*}"
+ # gh release create fails if the tag already has a release, so a
+ # published nightly's assets are never replaced.
+ gh release create "$TAG" release/*.zip release/SHA256SUMS \
+ --repo "$GITHUB_REPOSITORY" \
+ --title "Insomnia nightly ${day:0:4}-${day:4:2}-${day:6:2} (${GITHUB_SHA:0:12})" \
--notes-file notes.md \
--verify-tag \
- --prerelease
+ --prerelease \
+ --latest=false
diff --git a/.greptile/config.json b/.greptile/config.json
index 37b79133..45bef947 100644
--- a/.greptile/config.json
+++ b/.greptile/config.json
@@ -68,8 +68,8 @@
{
"id": "sudoers-rule-is-four-exact-lines",
"severity": "high",
- "scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "SECURITY.md", "docs/spec.md"],
- "rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, SECURITY.md and docs/spec.md list the same four."
+ "scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "docs/install.md", "SECURITY.md", "docs/spec.md"],
+ "rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, docs/install.md, SECURITY.md and docs/spec.md list the same four."
},
{
"id": "behavior-changes-come-with-regression-tests",
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4b52e283..d53aa6e7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,3 +43,11 @@ certification. Real-machine validation requires a supervised, separately
approved run on a ventilated surface with recoverable work. Record the exact
revision, macOS/hardware versions, observed result, and remaining limitations
in the release validation record. Never stress or heat a machine in a bag.
+
+The app icon is the menu bar's open eye on a charcoal tile.
+[AppIconArtwork](Sources/Insomnia/UI/AppIconArtwork.swift) draws it from the
+same [vector geometry](Sources/Insomnia/UI/EyeMarkGeometry.swift) as the menu
+bar's closed eye, which opens while a session runs. After changing the
+artwork, run `./scripts/generate-app-icon.sh`. It regenerates the packaged PNG
+and ICNS assets and the README's SVG offline, with Xcode's swiftc and
+iconutil.
diff --git a/README.md b/README.md
index 6436fe0f..351f7cd0 100644
--- a/README.md
+++ b/README.md
@@ -9,14 +9,15 @@
- A native macOS menu bar app for timed awake sessions. Pick a duration for your
- long-running work, choose what happens when the lid closes, and see when
- recovery needs your attention.
+ A macOS menu bar app for timed awake sessions. Set how long your Mac stays
+ awake, choose what happens when the lid closes, and see when something could
+ not be undone.
Install ·
Using it ·
+ Lid close ·
Recovery ·
Security
@@ -27,8 +28,8 @@
-> **Use a stable, well-ventilated surface—not a closed bag.** Insomnia is
-> experimental software. Recovery can fail; a running timer is not a safety
+> **Use a stable, well-ventilated surface, not a closed bag.** Insomnia is
+> experimental. Recovery can fail, and a running timer is not a safety
> guarantee.
> [Validation status](docs/release-validation.md) · [Apple's ventilation guidance](https://support.apple.com/en-us/102336)
@@ -38,62 +39,26 @@
## Install
-Requires **macOS 26 or later on an Apple Silicon Mac**.
+Requires macOS 26 or later on an Apple Silicon Mac.
Paste this into your coding agent:
```text
-Install Insomnia from https://github.com/krishhgg/Insomnia by following its README. If a step needs my password, give me the command to run in Terminal.
+Install the latest stable Insomnia release by following https://github.com/krishhgg/Insomnia/blob/main/skills/install-insomnia/SKILL.md. If a step needs my password, give me the command to run in Terminal.
```
-Or run it yourself:
+For the newest nightly, write "the newest Insomnia nightly prerelease" in
+place of "the latest stable Insomnia release". Nightlies are built from `main`
+on each day it has new commits. They have the newest code and may be broken.
-1. Download `Insomnia--macos.zip` and `SHA256SUMS` from the newest
- release on the [releases page](https://github.com/krishhgg/Insomnia/releases)
- (releases are marked Pre-release). If the page has no release yet, build
- from source (below).
-2. Verify the download (`gh` is the [GitHub CLI](https://cli.github.com)):
+To install a release yourself, follow [docs/install.md](docs/install.md). It
+covers both channels, how to check the download, and `v0.1.0`, which installs
+differently.
- ```bash
- shasum -a 256 -c SHA256SUMS
- gh attestation verify Insomnia--macos.zip -R krishhgg/Insomnia \
- --signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
- --source-ref refs/tags/v
- ```
+### Build from source
- The second command checks that this repository's Release workflow built
- this exact zip for that tag.
-
-3. Unzip and run the installer that comes in the zip:
-
- ```bash
- ditto -x -k Insomnia--macos.zip .
- cd Insomnia--macos
- ./install.sh --allow-unverified-origin --app ./Insomnia.app
- open "$HOME/Applications/Insomnia.app"
- ```
-
- Releases are ad-hoc signed and not notarized. The installer can check that
- the bundle is intact but not who made it, so it refuses to install without
- `--allow-unverified-origin`, which says you ran the two commands in step 2.
- macOS blocks the first launch of a downloaded copy until you allow it in
- System Settings > Privacy & Security.
-
-The installer checks the bundle's signature, identifier and version before it
-asks for anything. It then installs the app and a background recovery agent,
-and asks for administrator access to install a narrowly scoped sudoers rule. It grants
-**your user account**, not just Insomnia, passwordless access to four
-power-setting commands. Review that permission before installing.
-
-Release zips are built for arm64 only, and their `install.sh --app` stops on
-an Intel Mac. On Intel, building from source (below) is the only option, and
-it is untested there.
-
-### Build from source (experimental)
-
-Requires **Xcode with Swift 6.2 or later**. Clone the newest release tag
-rather than `main`. While no release exists, leave out `--branch v`
-to build `main`:
+Needs Xcode with Swift 6.2 or later. Replace `v` with the latest
+stable tag, or a nightly tag for the newest code:
```bash
git clone --branch v --depth 1 https://github.com/krishhgg/Insomnia.git
@@ -102,24 +67,19 @@ cd Insomnia
open "$HOME/Applications/Insomnia.app"
```
-`scripts/install.sh` builds the same bundle the release workflow builds
-(`scripts/build-app.sh`), ad-hoc signed, and installs it the same way. It
-builds only when it runs from a checkout's `scripts` folder, with
-`Package.swift` one level up, and then runs the `build-app.sh` beside it. The
-`install.sh` from a release zip stops and asks for `--app` instead, even when
-a `build-app.sh` was added to its folder after unpacking.
-[docs/releasing.md](docs/releasing.md) describes the release pipeline.
-
-Exactly what gets installed
+What the installer changes on your Mac
+
+The installer needs your password, because the sudoers rule is a system
+file. It installs:
-| Location | Purpose |
-| --- | --- |
-| `~/Applications/Insomnia.app` | The menu bar app, with `backstop.sh` sealed inside it at `Contents/Resources` |
-| `~/Library/Application Support/Insomnia/` | Configuration and the session/recovery journals |
-| `~/Library/LaunchAgents/com.insomnia.backstop.plist` | Per-user recovery agent: verifies the app's code signature, then runs the sealed `backstop.sh` |
-| `~/Library/Logs/Insomnia/` | `insomnia.log` and `handoffs.log`, each capped at 1 MiB with one older copy kept as `.1`, unless you replace it with a symlink |
-| `/etc/sudoers.d/insomnia` | Permission for the four commands below |
+- The app, at `~/Applications/Insomnia.app`.
+- A **recovery agent**, a `launchd` job that runs every minute. If the app is
+ gone when a session should have ended, the agent tries to undo the
+ session's changes.
+- A **sudoers rule** at `/etc/sudoers.d/insomnia`. A sudoers rule decides who
+ can run what with `sudo`. This one lets your user account, not just
+ Insomnia, run these four commands without a password:
```text
/usr/bin/pmset -a disablesleep 1
@@ -128,90 +88,35 @@ a `build-app.sh` was added to its folder after unpacking.
/usr/bin/pmset -b lowpowermode 0
```
-The grant is available to other processes running as your user. Insomnia is not
-sandboxed. The app, scripts, and journals are local; hotspot passwords use the
-login Keychain, not the configuration file.
-
-The recovery agent runs at login and every 60 seconds. Its command line pins
-the installed bundle's code requirement (for an ad-hoc build, the cdhash of
-that build) and runs `codesign --verify --strict` against it before executing
-the `backstop.sh` sealed inside the bundle. An edited bundle or script fails
-that check: the agent writes one line to `insomnia.log` and runs nothing until
-you reinstall. So no other account can edit it, the installer removes group
-and other write permission and every ACL from the bundle it installs. The
-signature covers neither, so the bundle still verifies, and extended
-attributes such as a download's quarantine flag are kept. No executable is
-kept in a writable support directory. The plist in `~/Library/LaunchAgents`
-is still a per-user file that any program running as you can edit, like
-every LaunchAgent; the app rewrites it at the next session start when it does
-not match, which is a repair, not a tamper check.
-
-What the app pins is the requirement of the code it is itself running, read
-through the Security framework after checking that the bundle on disk is still
-that code and still passes the agent's check. A bundle whose sealed script was
-edited, or that was re-signed under the running app, is refused rather than
-pinned: the app does not start a session, or reports the end as incomplete,
-and names the reason, until you reinstall. With ad-hoc signatures this guards
-against accidental edits and against the app relaying a tampered bundle into
-the agent, not against a process running as you: that process can edit the
-plist, load its own agent, quit the app and launch a replacement, and run the
-four `pmset` commands itself.
-
-An upgrade asks the running app to quit and stops if it refuses. The new
-bundle is built in a staging directory next to the app and moved into place in
-the same step that replaces the recovery agent. That step starts only after
-`launchctl print` confirms the previous agent is unloaded; otherwise nothing is
-replaced. If the new agent cannot be loaded, or its plist cannot be saved, the
-installer unloads it, waits for `launchctl print` to confirm that, and puts the
-previous bundle back, so the loaded agent always matches the installed app. A
-bundle that cannot be moved during that step is handled the same way: the
-previous bundle goes back and its agent is loaded again. If the previous bundle
-itself cannot be moved back, nothing is deleted: it stays at
-`~/Applications/.Insomnia.app.previous`, the installer prints the two commands
-that put it back and load its agent, and until then the agent finds no app, so
-run them or rerun the installer before you log out. If
-the unload is not confirmed, the new bundle stays with the agent that pins it
-and the installer asks you to rerun it. After that, or after an install killed
-in the middle of that step, the next run keeps whichever bundle the agent's
-plist on disk pins. It does that only after its own recovery step succeeds:
-while recovery is unresolved, an agent the earlier run left loaded may be the
-one retrying it, so the installer stops without unloading that agent or
-moving either bundle. Once recovery succeeds, it unloads that agent, moves the
-bundle back and loads the plist on disk again, and it stops if `launchctl
-print` does not confirm the unload or the reload. Unresolved recovery prevents
-replacing either; follow the reported instructions before retrying. The
-installer checks the sudoers rule again once it holds the recovery lock, and
-stops if the rule is gone, as after an `uninstall.sh` that took the lock first.
-Each call it makes to `sudo`, `pgrep`, `launchctl` or `codesign` while it
-holds the lock has a 30 s limit, which a supervising process enforces even
-if the installer is killed meanwhile. The call keeps the lock until it has
-exited or been stopped, so no `launchctl bootout` it started is still
-running once the lock is released. A call that does not answer in time gets SIGTERM, then SIGKILL
-one to two seconds later, and the install stops, so the lock is released and the app
-and the agent's backstop can take it again to undo a session. `sudo` only
-ever gets SIGTERM: one that ignores it keeps the lock until it ends, and the
-installer prints its pid.
+Any program running as you can use that rule too, so review it before you
+install. [docs/install.md](docs/install.md#what-gets-installed) lists every
+file and explains how upgrades replace them.
## Using it
-1. **Start:** click the eye in the menu bar, enter Days / Hours / Minutes, and
- press Enter.
-2. **Extend:** click the eye or countdown during a session and enter more time.
-3. **End early:** press and hold the end control beside the countdown.
-4. **Inspect or configure:** right-click for status, recovery warnings,
- **Settings**, and **Quit Insomnia**. Quitting requests session cleanup and
- can be refused while required recovery remains.
+1. **Start.** Click the eye in the menu bar, enter days, hours and minutes,
+ and press Enter.
+2. **Add time.** Click the eye or the countdown during a session.
+3. **End early.** Press and hold the end control next to the countdown.
+4. **Settings and status.** Right-click the eye. Quit asks Insomnia to undo
+ the session first, and it refuses while a change is still waiting to be
+ undone. Saved audio for an output that isn't connected doesn't hold it up.
+ Insomnia restores that output if it reconnects while the app runs, or at
+ the next launch.
-You do not need to close the lid to use a timed session. Opening the lid does
-not end it, and a sleeping display is not the same as a sleeping Mac.
+You don't have to close the lid, and opening it doesn't end the session.
-Before the first session, review the settings. Some lid actions are on by
-default, including pausing the apps on the freeze list (Slack, WhatsApp and
-Discord) while the lid is closed; pausing every other Dock app is off until
-you turn it on. Start with a short, supervised session on a ventilated surface
-and check the status menu and `~/Library/Logs/Insomnia/insomnia.log` afterward.
+On battery power, Insomnia turns on Low Power Mode below 40% and ends the
+session below 10%. Those are the defaults. You can change both in Settings,
+and an end floor of 0 turns the battery end off. The heat rules turn on Low Power
+Mode when the Mac gets seriously hot and end the session at critical heat.
+They are on by default, and Settings can turn them off. All of these rules
+only work while the app runs.
+
+Before your first session, check Settings. Then run a short session you can
+watch, and read `~/Library/Logs/Insomnia/insomnia.log` afterward.
## What happens when the lid closes
@@ -219,123 +124,30 @@ and check the status menu and `~/Library/Logs/Insomnia/insomnia.log` afterward.
-During a session, Insomnia turns the display and keyboard backlight off
-(saving their brightness first), pauses the apps on the freeze list (and, if
-you opt in, every other Dock app that is not an agent app), checks whether
-Docker Desktop is idle before pausing it, and saves then mutes audio.
-Reopening the lid attempts to undo those lid actions. If the lid opens
-while Insomnia is still checking Docker, Docker is left running and the undo
-starts right away. **The timer keeps counting down while the lid is
-closed**; only its on-screen redraw pauses, also for a session started with
-the lid already closed.
-
-The display step exists because the sleep guard stops macOS from doing it:
-with sleep disabled, closing the lid no longer turns the panel or the keys off
-by itself. Insomnia sets both to zero and restores them when the lid opens.
-Both go through private macOS frameworks. The display calls run only on a
-macOS major version they were measured on (26). The keyboard calls run only
-while the private keyboard class has the method signatures measured on 26,
-on whatever version. If either check refuses a device, Insomnia leaves it
-alone and Settings says why under the toggle. A level saved before an update
-that the check now refuses stays saved for a version that can restore it, and
-the menu says to set it with the brightness keys meanwhile. That version leaves
-a level you set by hand alone, and decides only on a reading taken with the
-display awake and the keys not dimmed. Once Insomnia's own Low Power Mode has
-been on over the saved display level, it leaves that level undecided until the
-Mac restarts, through relaunches of the app, and until then a lid close leaves
-that display lit and only asks it to sleep. The same goes when the recovery
-agent switches that mode off before Insomnia starts again, and when Insomnia
-switches off a mode still claimed from before a restart. That mode may read
-off then, yet it may have gone off only a moment before, so the level waits
-for the next restart even when the mode has been off for days. Once it has seen
-that display lit above zero, it never writes the saved level over a zero you
-set by hand, also after a relaunch or a restart. It cannot tell that zero
-from one auto-brightness left under a closing lid, so the saved level stays
-undecided, with nothing written, until you raise the display above zero. If Insomnia cannot record that it saw the display lit, or that the
-saved level is settled, Quit waits until it can.
-The display comes back to the brightness sampled while the lid was open, not
-the reading at the moment of closing (auto-brightness has already dimmed the
-panel under the closing lid by then, and Low Power Mode rescales it), and if
-Insomnia's own Low Power Mode was on while the lid was closed the value is
-written once more when the mode ends. If Insomnia is not running when you open
-the lid, press the brightness-up key.
-
-The defaults are worth knowing:
-
-- **Selected apps:** Slack, WhatsApp, and Discord are on the freeze list.
- Configured agent apps are excluded from this ordinary list.
-- **Every other app:** "Freeze every other app while the lid is closed" is
- off, so a fresh install pauses the freeze list only. Turn it on to also pause
- every Dock app that is not an agent app, an Apple app, Docker Desktop or a
- built-in protected app (editors, terminals, browsers, AI apps, password
- managers, local databases, Tailscale, local model servers; JetBrains IDEs by
- bundle-id prefix), so only agents keep running with the lid shut. Menu-bar
- apps are never picked up automatically; add them to the freeze list if you
- want them paused. Settings shows a "Would freeze now" line listing what the
- automatic scope would pause at that moment.
-- **Meeting, recording and dictation apps:** never frozen by "Freeze every
- other app", with their helper apps: Zoom, Microsoft Teams (new and classic),
- Webex (and the older Webex Meetings app), FaceTime, Wispr Flow, Granola,
- Otter, OBS and Loom. Freezing one ends the call, the recording or the
- meeting notes when the lid closes. Putting one on the freeze list by hand
- still freezes it, except FaceTime, which is an Apple app.
-- **Docker rule:** off. Turn it on to pause Docker Desktop on lid close when
- no container is running. The local Desktop socket is asked once to pick
- Docker up and once more right before the pause; a busy answer, a failed
- `docker ps` or a timeout at either point leaves Docker running. A container
- that starts between the second check and the pause is still paused with
- Desktop, so leave the rule off for Docker workloads an unexpected pause
- would hurt.
-- **Mute on close:** on, so sound stops when the lid closes. Lid open
- restores each output that was muted, even if another one is in use by then.
- An output that is not connected stays muted until it reconnects: Insomnia
- restores it then if it is running, or at the next launch. Ending or
- quitting a session does not wait for it. The end notification and the menu
- name it, and the menu's "Stop waiting for " leaves it as it is. A
- restore that fails on a connected output is retried by Insomnia while it
- runs, and at the next launch.
-- **Microphone:** on Mac laptops with Apple silicon or a T2 chip, closing the
- lid disconnects the built-in microphone in hardware. Recording a meeting
- with the lid closed needs AirPods or an external mic; Settings says the same
- next to the lid-close options.
-- **Display and keyboard backlight:** on ("Turn off the display and keyboard
- backlight" in Settings). Both values are saved to the journal before they
- are changed.
-- **Low Power Mode while the lid is closed:** on. With sleep disabled a closed
- Mac otherwise keeps running at full speed; the mode is switched off again
- when the lid opens, unless a battery or thermal rule still wants it.
-- **Battery rules:** below 40% on battery, request Low Power Mode; below 10%,
- end the session. A battery that is present but cannot be read on two
- consecutive reads while on battery also ends the session, with a
- notification saying why: the floor cannot be applied to a level nobody can
- read. One failed read is tolerated, and the level is re-read every 30 s
- until it is readable again. A desktop has no battery and no floor. Serious
- thermal state requests Low Power Mode; critical thermal state ends the
- session. These rules require the app to be running.
- Setting the end floor to 0 turns the battery end off. Otherwise the end
- floor stays below the Low Power Mode floor. The Settings steppers move the
- other floor when the two would cross, and a hand-edited `config.json` with
- the floors out of order is corrected at launch, and logged, by raising the
- Low Power Mode floor.
-
-Upgrading from an earlier build changes two of these once. On the first
-launch of this version, a config.json saved by an earlier build gets "Freeze
-every other app" turned off and "Mute audio on lid close" turned on, and
-Insomnia posts a notification naming what changed. Settings shows the same
-line at the top of Lid-close actions until you dismiss it, for anyone with
-notifications off. The toggles are right below it. config.json records that
-the update ran (`lidCloseDefaultsApplied`), so a setting you turn back stays
-the way you set it. A fresh install starts with the new defaults and no
-notice.
-
-To exercise the lid actions without closing the lid, run
-`scripts/simulate-lid.sh closed` and then `scripts/simulate-lid.sh open` during
-a session; the app runs the same actions it would on a real lid event. Only a
-build with the file watcher compiled in reads that trigger: a debug build, or
-a release build installed with `INSOMNIA_LID_SIMULATION=1 ./scripts/install.sh`.
-A normal install has no watcher, so no program running as your user can replay
-the lid actions by writing a file. A build that has it logs "Lid simulation
-build" at launch and shows the same line in the status menu and in Settings.
+During a session, closing the lid does each of these, and opening it tries
+to undo them. The timer keeps counting down while the lid is closed.
+
+- **Display and keyboard light go off.** Insomnia saves their brightness
+ first. With sleep turned off, macOS no longer does this by itself.
+- **Apps on the freeze list are frozen.** Freezing sends an app's processes
+ `SIGSTOP`, which pauses them until `SIGCONT` on lid open. The list starts
+ with Slack, WhatsApp and Discord. "Freeze every other app" is off by
+ default. Even when on, it skips meeting, recording and dictation apps: Zoom,
+ Teams, Webex, FaceTime, Wispr Flow, Granola, Otter, OBS and Loom.
+- **Audio is muted.** Lid open unmutes each output Insomnia muted, even if you
+ switched to another one.
+- **Low Power Mode turns on.** It turns off when the lid opens, unless a
+ battery or heat rule still needs it.
+- **Docker Desktop is frozen when no container runs.** This one is off by
+ default.
+
+On Mac laptops with Apple silicon or a T2 chip, closing the lid disconnects
+the built-in microphone in hardware. Recording a meeting with the lid closed
+needs AirPods or an external mic.
+
+[docs/lid-close.md](docs/lid-close.md) has the details: how Docker is checked,
+how brightness is restored, the one-time settings change when you upgrade,
+and how to test lid actions without closing the lid.
## How recovery works
@@ -343,245 +155,71 @@ build" at launch and shows the same line in the status menu and in Settings.
-Insomnia records pending changes in a recovery journal. On session end, the app
-attempts to undo them. An independent `launchd` agent checks every minute and
-can attempt recovery after the app exits unexpectedly, once the saved deadline
-has passed. It leaves a valid, unexpired session alone.
-
-The app and backstop use the same lock so they do not restore and rewrite the
-journal over one another. Failed restoration keeps the relevant entries;
-unreadable journals are preserved instead of treated as clean. A session file
-that does not parse counts as expired and is renamed to
-`session.json.unreadable-` beside it, never deleting or overwriting
-anything: the app does this at launch, before restoring whatever the journal
-holds, and says where the file went; the agent does it once the journal is
-clean. A session file that cannot be read at all (permissions, or not a
-regular file, which is never opened) also counts as expired, since its end
-time is unknown: the journal is restored and the file is renamed the same
-way without being opened, so a later launch cannot resume a session that
-was treated as ended. The app says where it went. If the rename fails, the
-app keeps trying it and will not quit until the file is gone.
-`uninstall.sh --purge` removes the renamed copies that are regular files;
-without `--purge` they stay.
-
-**Recovery is not “everything always gets undone.”** The backstop does not
-monitor battery or temperature. Saved audio needs the app to reopen, and
-unconfirmed process freezes may need manual inspection. If a warning remains,
-resolve it before leaving the Mac unattended. Real-machine crash, reboot, and
-installation scenarios still need [release validation](docs/release-validation.md).
+Insomnia writes each change to a **recovery journal**, a file on disk, before
+it makes the change. Ending the session tries to undo what the journal lists.
+If the app crashes or quits, the recovery agent tries the same once the
+session's end time has passed.
-
-Recovery limits and manual attention
-
-- **Process ownership:** automatic resume checks the recorded process start
- time and boot session. The app journals each pid before it sends SIGSTOP,
- and sends nothing when that write fails. The identity is added only after
- the kernel confirms Insomnia's own stop, so a process somebody else had
- stopped is never resumed. If the confirming write fails, the app sends
- SIGCONT at once to each pid it just stopped whose identity still matches,
- even one that does not show as stopped yet: SIGCONT also cancels a stop
- that is still pending. It then drops those entries from the journal,
- shows the failure in the status menu, and stops counting them as frozen
- even if the disk refuses that write too. If the app dies between the stop and that write,
- the stopped pids stay journaled without identity, like entries from builds
- that recorded the pid alone, and are not automatically resumed while
- stopped. Verify the live process and whether it should be resumed; never
- blindly signal a PID from an old log.
-- **Identity is not an atomic guarantee:** the app checks start time to the
- microsecond, and the backstop asks the installed app binary
- (`Insomnia --resume-frozen`) to do the same check and send the signal for
- every entry that records microseconds, all such entries in one call with a
- 30-second limit. The entries go to the binary on standard input, so a long
- journal cannot exceed the argument size limit. The backstop never signals those entries itself. It keeps
- them when the binary is missing, does not finish in time, or answers
- anything but one expected line per entry. It runs the binary only when the
- installed bundle declares `InsomniaResumeFrozenVersion` in its
- `Info.plist`, so it never starts an older build. The binary holds the
- recovery lock while it can still send a signal and ends itself after the
- same limit, so a backstop run that is killed mid-call leaves no helper
- that could act later without the lock. `uninstall.sh` uses the backstop
- installed with an app that does not declare that version. With no such
- copy, the checkout's backstop keeps those entries and uninstall stops
- before removing anything. Entries written by builds before
- microseconds were recorded keep the one-second `ps` comparison in the
- shell. A lookup and a signal are still separate operations, one pid at a
- time.
-- **Stuck power commands:** a `sudo pmset` that has not finished after 20 s
- is sent SIGTERM, never SIGKILL: killing sudo could leave a root pmset
- changing power settings after the journal has moved on. If it is still
- running 3 s later the transaction stops where it is, as the backstop's
- does: nothing else is undone, the journal keeps its entries, and the
- recovery lock stays held until the command exits. The command holds the
- lock itself (its stdin is a descriptor on the lock file), so if Insomnia
- crashes or is force-quit meanwhile, the backstop still waits for the
- command instead of running an undo the command would then override. A
- notification and a menu warning give the pid and `sudo kill `; the
- warning goes away when the command exits. The pid is also written to
- `unfinished-command.json` with the command's start time and boot
- session. A relaunch that finds the lock busy names the command in the
- menu. It gives the pid and `sudo kill`, in one notification as well,
- only while that pid still has the recorded start time and boot session;
- otherwise it says the command has exited, since the pid may now belong
- to another process. The relaunch tries again 30 s after each refusal.
- Once the command has exited, it resumes a session that has not expired,
- with its battery floors, and checks Low Power Mode the way it does after
- its own command exits, described below. A session the user starts
- before that next try gets the same check. Until the command exits,
- Insomnia refuses to quit or start a session, and records any end or lid
- event it refuses. A
- `disablesleep 0` or `lowpowermode 0` that exits 0 counts as done: its
- journal entry is cleared before the lock is released, and the command
- is not run again. If that journal write fails, the menu says so and the
- undo runs again; the line goes once a later write clears the entry. Any
- other exit counts as a failure. Then a pending end runs again.
- Otherwise Insomnia reads Low Power Mode. If it reads off, Insomnia runs
- its own `lowpowermode 0` and forgets the mode only once that succeeds.
- Then it replays a refused lid event, after waiting out the 2 s lid
- debounce, and runs the floor rules again. If the mode cannot be read or
- switched off, or the journal cannot be written, it tries again every
- 30 s while the session lasts.
-- **Audio:** the backstop preserves volume/mute entries but cannot restore
- CoreAudio. Reopen the app for recovery. An output device that is not
- connected keeps its entry until it reconnects, and uninstall stops while
- one is waiting: connect it and open Insomnia, or choose "Stop waiting for
- " in the menu.
-- **Sleep disabled by something else:** at launch, with no session and no
- journal entry, a `SleepDisabled 1` in `pmset -g` is left alone: Insomnia
- did not set it and only its owner should undo it. The menu shows a warning
- and a notification gives the command, `sudo pmset -a disablesleep 0`.
- Ending an Insomnia session sets it to 0 whoever set it.
-- **Low Power Mode:** Insomnia checks the existing setting so it does not
- claim ownership of an already-enabled preference.
-- **App Nap:** off by default. When the setting is on, Insomnia journals each
- agent app's previous `NSAppSleepDisabled` value before writing it and puts
- it back at session end, in the backstop, and in uninstall. Values an older
- build wrote without a record are not guessed at: uninstall prints the
- `defaults delete` command for each one and continues.
-- **Uninstall:** refuses to remove recovery machinery while unresolved changes
- remain. A failed uninstall is not confirmation that power settings are normal.
+Some things need you:
-
+- The recovery agent can't restore audio. Open Insomnia again for that.
+- The recovery agent doesn't watch the battery or the temperature.
+- Insomnia only unfreezes a process it can prove it froze. A frozen process
+ it can't prove stays frozen until you check it.
+
+If the menu shows a recovery warning, deal with it before you leave the Mac
+alone. [docs/recovery.md](docs/recovery.md) explains each case.
## Optional extras
iPhone hotspot handoff and tmux
-Set **System Settings → Wi-Fi → Ask to join hotspots → Automatically**, then
-enter the hotspot SSID and password in Insomnia Settings. The password is
-stored in the login Keychain under service `insomnia-hotspot`. Insomnia uses
-CoreWLAN to find and join that network without putting the password in process
-arguments.
-
-The Keychain item's access list names only the build of Insomnia that saved
-it, and Insomnia reads it with Keychain prompts switched off, so a join during
-an outage never raises a dialog. The installer signs each build ad hoc, which
-gives every install a new identity: after a reinstall the saved password is
-unreadable by the new build. Insomnia then skips the join, shows "Hotspot
-password unreadable by this build" in the right-click menu and in Settings,
-and sends one notification per outage. The warning belongs to the SSID it was
-read for. Change the SSID and it goes, and Settings checks the new SSID's
-saved password instead. Enter the password again in Settings and save; the
-save writes the new password before it removes the old item,
-and macOS may ask you to allow Insomnia to delete the old one, or to unlock
-the login keychain. If that save is cut off after the old item is gone,
-the password reads as missing and you enter it once more: Insomnia never
-reads a half-finished save's copy. The Save button reads "Saving…" until
-macOS answers, and "Saved" only while the SSID and password fields still
-hold what was saved. A join that was waiting while you changed the SSID is
-dropped, and the next retry uses the new SSID. A Settings read that was
-waiting is dropped too, and Settings reads the new SSID's password instead.
-Anything you type in the password field while Settings is still loading the
-saved one stays, even if you delete it again. The rest of Insomnia,
-including the battery floor and End, keeps running while the dialog is open.
-A build signed with a stable identity would keep the item readable across
-upgrades.
-
-macOS requires Location Services permission to reveal network names. Insomnia
-requests it on the first hotspot save, or when starting a session with a
-configured hotspot—not merely on launch. If denied, use the Location row in
-Settings to open **Privacy & Security → Location Services**. Mac apps have no
-when-in-use grant, so System Settings records it as Location Services access
-for Insomnia. Insomnia uses it only to read Wi-Fi network names through
-CoreWLAN and never requests your location.
-
-After a long outage (90 seconds by default) Insomnia types `continue` into
-each configured tmux target. The default target list is empty, and a listed
-pane is only nudged if you have marked it yourself, with a pane option that
-is read again before every send:
+If the Wi-Fi drops during a session, Insomnia can join your iPhone's hotspot.
+Set System Settings > Wi-Fi > Ask to join hotspots to Automatically, then enter
+the hotspot's name and password in Insomnia's Settings. The password stays in
+your login Keychain. Insomnia asks for Location Services access, because
+macOS requires it to read Wi-Fi network names. It never reads your location.
+After you reinstall Insomnia, enter the password again.
+
+After 90 seconds without a network, Insomnia can also type `continue` into a
+tmux pane running a coding agent. It only types into panes you list in
+Settings and mark yourself:
```bash
tmux set-option -p -t @insomnia-nudge on
```
-Mark a dedicated, disposable agent pane, not one you type in, because pending
-text is opaque to Insomnia. Enter is off by default, so the word is typed and
-nothing submits it. Turn on "Press Enter after continue" in Settings to submit
-it, knowing that Enter also submits anything already typed in that pane. The
-option must be on the pane itself (`-p`). One set on the session or window
-does not count. Pane options need tmux 3.0 or later. Ending a session cancels
-pending automation but cannot retract keystrokes already sent.
+It doesn't press Enter unless you turn that on. Mark a pane you don't type in.
+[docs/extras.md](docs/extras.md) has the details.
-Chrome, Chromium, and Arc throttling
-
-Chromium browsers can throttle windows macOS considers occluded, including
-when the lid is closed. Insomnia detects supported running browsers missing
-`--disable-backgrounding-occluded-windows` or `--disable-renderer-backgrounding`
-and offers **Relaunch [browser] unthrottled** in the right-click menu. The item
-asks first, because the browser is quit and its windows and tabs come back only
-if it is set to reopen them on startup. If the browser has quit by the time you
-confirm, nothing is quit or launched and a notification says so. Insomnia reads the browser's profile
-arguments before quitting and carries them over. If it cannot read them, cannot
-read the kernel's start time that ties them to the browser, or the browser
-quits on its own while they are read, it quits nothing and says so. If the
-browser has not quit after 10 s, nothing is launched, and a notification says
-so: a second copy beside the first would be worse than a throttled one. The
-quit request stands, so a browser that closes later has to be opened again by
-hand. After `open` returns, Insomnia waits up to 5 s for the browser to show up
-as running and notifies if it does not. Each of these reasons also stays in the
-right-click menu as a warning line, one per browser, until that browser's next
-relaunch or the next session, so it is there even with notifications off. A
-relaunch that ends after its session ended, or after a newer relaunch of the
-same browser started, reports nothing. This is not a guarantee that every web
-app will keep working while the lid is closed.
+Chrome, Chromium and Arc throttling
+
+These browsers slow down windows that macOS reports as hidden, which includes
+every window while the lid is closed. If a running browser lacks
+`--disable-backgrounding-occluded-windows` or
+`--disable-renderer-backgrounding`, the right-click menu offers "Relaunch
+[browser] unthrottled". It asks first, because relaunching quits the browser,
+and tabs come back only if the browser reopens them on startup. Some web apps
+may still stop with the lid closed.
+[docs/extras.md](docs/extras.md#chrome-chromium-and-arc-throttling) has the
+details.
Configuration and privacy
-Configuration lives in `~/Library/Application Support/Insomnia/config.json`.
-Use Settings for the app's controls; [Config.swift](Sources/Insomnia/Model/Config.swift)
-defines the full configuration and defaults. Local logs can contain SSIDs,
-process metadata, and tmux targets. Check them before sharing publicly.
-Lines the app writes to `insomnia.log` also go to the unified log with their
-bodies marked private, so `log show` and other local programs see ``
-in place of the text unless private data logging is enabled on the Mac. The
-backstop's lines go only to `insomnia.log`, which keeps the full text of both.
-The files in Application Support/Insomnia and Logs/Insomnia (config, session,
-journal, recovery lock, the record of a power command left running, the two
-logs) are owner-only, mode 0600 with those two directories 0700, and one left
-looser by an older build is tightened the next time the app or the backstop
-opens it. Insomnia sets only these modes and
-leaves any access control list (ACL) on these files and folders as it is, so
-an ACL someone added, or one inherited from a parent folder, can still give
-another account access (`ls -le` shows it). The LaunchAgent plist and the installed
-scripts hold no private data and keep the modes the installer gives them.
-`insomnia.log` and `handoffs.log` are capped at 1 MiB: a
-log past the cap is renamed to `insomnia.log.1` or `handoffs.log.1`,
-replacing the previous copy, and a new file starts. The cap does not apply
-to a log you replace with a symlink. Insomnia writes through the link and
-never rotates it, since the rename would move the link and not the file it
-points to, and it logs that once. You set up the link, so trimming the file
-it points to is up to you.
-
-`INSOMNIA_HOME` relocates app support files, logs, and LaunchAgents for testing.
-It is **not an installation sandbox**: installation/removal also involves the
-app bundle and sudoers rule. The installer refuses a relocated home. See
-[Paths.swift](Sources/Insomnia/Store/Paths.swift) for the layout.
+Settings live in `~/Library/Application Support/Insomnia/config.json` and logs
+in `~/Library/Logs/Insomnia/`. Insomnia sets both folders and their files to
+owner-only permissions. It leaves symlinks and access control lists (ACLs,
+extra per-account permission entries) as they are, so another account can
+still have access through one. Logs can contain Wi-Fi names, process details
+and tmux targets, so check them before you share one.
+[docs/extras.md](docs/extras.md#configuration-and-privacy) has the details.
@@ -590,28 +228,13 @@ app bundle and sudoers rule. The installer refuses a relocated home. See
From your checkout:
```bash
-./scripts/uninstall.sh
-# Also remove Insomnia-owned configuration and logs:
-./scripts/uninstall.sh --purge
+./scripts/uninstall.sh # removes the app, the agent and the sudoers rule
+./scripts/uninstall.sh --purge # also removes settings and logs
```
-From the unpacked release zip, run `./uninstall.sh` (or `./uninstall.sh
---purge`) in the `Insomnia--macos` folder. A checkout's uninstaller
-(in `scripts`, with `Package.swift` one level up) runs the `backstop.sh`
-beside it. Anywhere else, such as the zip's folder, the uninstaller runs only
-the copy sealed in the installed app, after `codesign --verify --strict`
-passes on the app, and stops without removing anything when there is none.
-The zip has no `backstop.sh`, so one added beside its uninstaller is not run.
-Neither looks in the folder above its own.
-
-The uninstaller requests cleanup before removing the app, agent, and sudoers
-rule. If recovery is incomplete or the app refuses to quit, it stops; resolve
-the reported problem and retry. With the app it removes what an interrupted
-install left beside it: `~/Applications/.Insomnia.app.previous`, and
-`.Insomnia.app.staging.*` directories of installs that are no longer running.
-Nothing else in `~/Applications` is touched. Purge removes owned files, not arbitrary
-directory contents. A small shared lock file is retained to keep concurrent
-recovery operations coordinated.
+From an unpacked release zip, run `./uninstall.sh` in its folder instead. The
+uninstaller undoes any open session first and stops if it can't.
+[docs/install.md](docs/install.md#uninstall) has the details.
## Development
@@ -620,20 +243,19 @@ swift build
swift test
```
-CI runs Swift tests, a release build with warnings as errors, a bash 3.2
-syntax check of the scripts, ShellCheck, and actionlint plus zizmor over the
-workflows. tmux integration tests need tmux installed; check skip counts
-rather than assuming missing integration coverage passed. Tests use injected
-dependencies and temporary fixtures—not live installation or power changes
-on a contributor's machine.
+The tests use fakes and temporary folders, so they never change your power
+settings. See [Contributing](CONTRIBUTING.md), [Security reporting](SECURITY.md),
+[Release validation](docs/release-validation.md) and [Design notes](docs/spec.md).
-The app icon is the menu bar's open eye on a charcoal tile: [AppIconArtwork](Sources/Insomnia/UI/AppIconArtwork.swift) draws it from the same [vector geometry](Sources/Insomnia/UI/EyeMarkGeometry.swift) as the menu bar's closed eye, which opens while a session runs.
-After changing the artwork, run `./scripts/generate-app-icon.sh` to regenerate
-the packaged PNG and ICNS assets and the README's SVG. The script draws them
-offline with Xcode's swiftc and iconutil.
+## Star history
-[Contributing](CONTRIBUTING.md) · [Security reporting](SECURITY.md) ·
-[Release validation](docs/release-validation.md) · [Design notes](docs/spec.md)
+
+
+
+
+
+
+
## License
diff --git a/SECURITY.md b/SECURITY.md
index 05a004ec..3bcbf397 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -45,8 +45,9 @@ ignores the data-protection accessibility classes (this device only, when
unlocked); using that Keychain needs an access-group entitlement, which needs
a team id. Releases are ad-hoc signed, so both limits stay.
-The recovery LaunchAgent runs only the `backstop.sh` sealed inside the signed
-app bundle, after `codesign --verify --strict` passes against the code
+In releases built by the Release workflow and in builds of the current
+source, the recovery LaunchAgent runs only the `backstop.sh` sealed inside the
+signed app bundle, after `codesign --verify --strict` passes against the code
requirement pinned in its plist (the build's cdhash for an ad-hoc signature).
An edited bundle or script is refused and logged. The app pins the code it is
running (its own designated requirement, after confirming that the bundle on
@@ -59,20 +60,38 @@ tampered bundle; they are not a boundary against a process running as the
same user, which can edit the plist, load its own agent, replace and relaunch
the app, and invoke the four pmset commands directly.
-Release zips are built by the Release workflow from the tagged commit and
-published with a `SHA256SUMS` file and a GitHub build provenance attestation.
-Verify both before installing (`shasum -a 256 -c SHA256SUMS`,
+Release zips on both channels, stable releases (`v` tags) and
+nightly prereleases (`nightly-*` tags built from `main`), are built by the
+Release workflow from the commit they name and published with a
+`SHA256SUMS` file and a GitHub build provenance attestation. Verify both
+before installing (`shasum -a 256 -c SHA256SUMS`,
`gh attestation verify -R krishhgg/Insomnia --signer-workflow
-krishhgg/Insomnia/.github/workflows/release.yml --source-ref
-refs/tags/v`); `install.sh --app` then checks the signature,
-identifier and version of a private copy of the bundle before asking for a
-password, installs that copy, and refuses
+krishhgg/Insomnia/.github/workflows/release.yml --source-ref [
+--source-digest ]`, with `refs/tags/v` or `refs/heads/main`
+as the ref and the full commit the release was built from as the digest; the
+release notes carry the command filled in). `install.sh
+--app` then checks the signature, identifier and version of a private copy
+of the bundle before asking for a password, installs that copy, and refuses
it unless `--allow-unverified-origin` is given, because it cannot verify where
a bundle came from, whatever its signature names. The attestation shows which workflow run produced
the bytes, not that the code is free of defects. Releases are ad-hoc signed and
not notarized ([docs/releasing.md](docs/releasing.md)), so macOS blocks their
first launch.
+The first stable release, `v0.1.0`, is the exception. It was packaged by hand
+from the app installed on the maintainer's Mac, so it has no attestation: the
+`SHA256SUMS` file and the per-file hashes in its release notes are the only
+check on the download. Its `scripts/install.sh --app` runs
+`codesign --verify --strict --deep` and checks the bundle identifier before
+the password prompt, but it has no `--allow-unverified-origin` opt-in: it
+installs any intact bundle it is given that has that identifier and the
+0.1.0 layout. It does not check the version. Its recovery agent predates the
+sealed script and the pinned requirement described above: the installer
+copies `backstop.sh` into `~/Library/Application Support/Insomnia/`, and the
+agent runs that copy with `/bin/bash` without checking any code signature, so
+an edited copy runs as it is. The `README.md` in its zip describes that
+layout and its limits.
+
Passing automated checks or a secret scan does not establish the absence of
vulnerabilities. Do not probe recovery by disrupting someone else's processes,
power settings, network, or data.
diff --git a/Sources/Insomnia/Core/SleepGuard.swift b/Sources/Insomnia/Core/SleepGuard.swift
index a9cedd0a..14ae219f 100644
--- a/Sources/Insomnia/Core/SleepGuard.swift
+++ b/Sources/Insomnia/Core/SleepGuard.swift
@@ -84,7 +84,7 @@ struct PmsetSleepGuard: SleepGuarding {
throw SleepGuardError(command: "pmset -g custom", status: r.status, stderr: r.stderr)
}
guard let on = Self.parseLowPowerMode(r.stdout) else {
- throw SleepGuardError(command: "pmset -g custom", status: 0, stderr: "no lowpowermode line under Battery Power")
+ throw SleepGuardError(command: "pmset -g custom", status: 0, stderr: "no lowpowermode or powermode line under Battery Power")
}
return on
}
@@ -103,12 +103,19 @@ struct PmsetSleepGuard: SleepGuarding {
}
/// `lowpowermode` in the `Battery Power:` section of `pmset -g custom`
- /// (the one `pmset -b` writes). nil when the section or key is absent,
- /// as on a desktop, or when the value is anything but an explicit `0`
- /// or `1`: an unreadable value is not proof that the mode is off, and
- /// treating it as off would take over a preference the user may have set.
+ /// (the one `pmset -b` writes). macOS 27 no longer prints that key and
+ /// reports the mode as `powermode` instead (`0` automatic, `1` Low Power,
+ /// `2` High Power), while `pmset -b lowpowermode 0|1` still sets it, so
+ /// `powermode` is read when `lowpowermode` is absent. A `lowpowermode`
+ /// line always wins over `powermode`. nil when the section or both keys
+ /// are absent, as on a desktop, or when the value is anything but an
+ /// explicit `0` or `1`: an unreadable value is not proof that the mode is
+ /// off, and treating it as off would take over a preference the user may
+ /// have set. High Power (`powermode 2`) is such a preference.
static func parseLowPowerMode(_ output: String) -> Bool? {
var inBattery = false
+ var lowPowerValue: Substring?
+ var powerModeValue: Substring?
for rawLine in output.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasSuffix(":") {
@@ -117,14 +124,15 @@ struct PmsetSleepGuard: SleepGuarding {
}
guard inBattery else { continue }
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
- guard parts.count >= 2, parts[0] == "lowpowermode" else { continue }
- switch parts[1] {
- case "1": return true
- case "0": return false
- default: return nil
- }
+ guard parts.count >= 2 else { continue }
+ if parts[0] == "lowpowermode", lowPowerValue == nil { lowPowerValue = parts[1] }
+ if parts[0] == "powermode", powerModeValue == nil { powerModeValue = parts[1] }
+ }
+ switch lowPowerValue ?? powerModeValue {
+ case "1"?: return true
+ case "0"?: return false
+ default: return nil
}
- return nil
}
/// The one path for every `sudo pmset` the app runs, including a check
diff --git a/Sources/Insomnia/System/DisplayPower.swift b/Sources/Insomnia/System/DisplayPower.swift
index 6e927752..c4826318 100644
--- a/Sources/Insomnia/System/DisplayPower.swift
+++ b/Sources/Insomnia/System/DisplayPower.swift
@@ -86,7 +86,7 @@ enum DisplayPower {
/// type information, so on any other major the calls are refused until
/// someone measures them again, rather than passing a display id and a
/// float pointer into a function whose signature may have changed.
- static let measuredDisplayServicesMajors: Set = [26]
+ static let measuredDisplayServicesMajors: Set = [26, 27]
/// nil when `major` was measured; otherwise why DisplayServices is refused.
static func displayServicesRefusal(osMajorVersion major: Int) -> String? {
@@ -160,7 +160,7 @@ struct NoopKeyboardBacklight: KeyboardBacklighting {
func isSuppressedOrDimmed() -> Bool { false }
}
-/// Private DisplayServices.framework, measured on macOS 26 (see
+/// Private DisplayServices.framework, measured on macOS 26 and 27 (see
/// docs/release-validation.md): Get/SetBrightness take effect at once
/// whether the display is awake, asleep or held by a display assertion.
/// Symbols are resolved once, lazily, under a lock, and only on a macOS
diff --git a/Tests/InsomniaTests/PmsetParsingTests.swift b/Tests/InsomniaTests/PmsetParsingTests.swift
index cadecae5..057b1f5b 100644
--- a/Tests/InsomniaTests/PmsetParsingTests.swift
+++ b/Tests/InsomniaTests/PmsetParsingTests.swift
@@ -102,4 +102,40 @@ final class PmsetParsingTests: XCTestCase {
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode 2\n"))
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode (unknown)\n"), "an unreadable value was taken as proof the mode is off")
}
+
+ // `pmset -g custom` on macOS 27.0.1 (MacBook Pro, M5 Pro): no
+ // `lowpowermode` key, the mode is reported as `powermode`.
+ let custom27 = """
+ Battery Power:
+ lidwake 1
+ powermode 1
+ sleep 1
+ AC Power:
+ lidwake 1
+ powermode 0
+ sleep 1
+ """
+
+ func testLowPowerModeIsReadFromPowermodeWhenLowpowermodeIsAbsent() {
+ XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode(custom27), true)
+ XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode 0\n"), false)
+ XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("AC Power:\n powermode 1\n"), "read the AC value instead of the battery value")
+ let swapped = custom27.replacingOccurrences(of: "powermode 1", with: "powermode X")
+ .replacingOccurrences(of: "powermode 0", with: "powermode 1")
+ .replacingOccurrences(of: "powermode X", with: "powermode 0")
+ XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode(swapped), false, "read the AC value instead of the battery value")
+ }
+
+ /// High Power is a preference the user set: it must read as unknown, not
+ /// as "off", or the session end's `lowpowermode 0` would replace it.
+ func testHighPowerModeIsUnknownNotOff() {
+ XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode 2\n"))
+ XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode (unknown)\n"))
+ }
+
+ func testLowpowermodeWinsOverPowermode() {
+ XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode 0\n lowpowermode 1\n"), true)
+ XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode 0\n powermode 1\n"), false)
+ XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode 2\n powermode 1\n"), "an unreadable lowpowermode fell back to powermode")
+ }
}
diff --git a/Tests/InsomniaTests/PrivateDisplayGuardTests.swift b/Tests/InsomniaTests/PrivateDisplayGuardTests.swift
index e3817fb1..397f69e8 100644
--- a/Tests/InsomniaTests/PrivateDisplayGuardTests.swift
+++ b/Tests/InsomniaTests/PrivateDisplayGuardTests.swift
@@ -1,6 +1,12 @@
import XCTest
@testable import Insomnia
+/// The measured DisplayServices majors as the refusal message names them, and a major past all of them. The
+/// assertions below are built from `DisplayPower.measuredDisplayServicesMajors`, so measuring the next macOS is a
+/// one-line change there rather than an edit to every expected message.
+private let measuredMajorsText = "measured on macOS \(DisplayPower.measuredDisplayServicesMajors.sorted().map(String.init).joined(separator: ", ")) only"
+private let unmeasuredMajor = (DisplayPower.measuredDisplayServicesMajors.max() ?? 0) + 1
+
// Fake KeyboardBrightnessClient classes. They carry the private class's
// selectors, and the Objective-C runtime reports their type encodings the
// same way it reports the real ones, so the guard is exercised for real
@@ -84,10 +90,11 @@ final class PrivateDisplayGuardTests: XCTestCase {
func testDisplayServicesIsAllowedOnMeasuredMajorsOnly() throws {
XCTAssertNil(DisplayPower.displayServicesRefusal(osMajorVersion: 26))
- for major in [15, 25, 27, 30] {
+ XCTAssertNil(DisplayPower.displayServicesRefusal(osMajorVersion: 27))
+ for major in [15, 25, unmeasuredMajor, unmeasuredMajor + 3] {
let why = try XCTUnwrap(DisplayPower.displayServicesRefusal(osMajorVersion: major))
XCTAssertTrue(why.contains("this is macOS \(major)"), why)
- XCTAssertTrue(why.contains("measured on macOS 26 only"), why)
+ XCTAssertTrue(why.contains(measuredMajorsText), why)
}
}
@@ -95,11 +102,11 @@ final class PrivateDisplayGuardTests: XCTestCase {
/// throws before the framework is opened, with the reason Settings
/// shows, and the refusal is logged once.
func testTheDimmerOnAnUnmeasuredMacOSRefusesEveryBrightnessCall() throws {
- let dimmer = DisplayServicesDimmer(osMajorVersion: 27)
+ let dimmer = DisplayServicesDimmer(osMajorVersion: unmeasuredMajor)
let why = try XCTUnwrap(dimmer.refusal())
- XCTAssertTrue(why.contains("this is macOS 27"), why)
+ XCTAssertTrue(why.contains("this is macOS \(unmeasuredMajor)"), why)
XCTAssertThrowsError(try dimmer.readBrightness()) { XCTAssertEqual($0.localizedDescription, why) }
XCTAssertThrowsError(try dimmer.setBrightness(0)) { XCTAssertEqual($0.localizedDescription, why) }
XCTAssertEqual(dimmer.refusal(), why)
@@ -234,7 +241,7 @@ final class RefusedDarkeningTests: XCTestCase {
}
private var refusedDisplay: BrightnessOnlyDimmer {
- BrightnessOnlyDimmer(inner: DisplayServicesDimmer(osMajorVersion: 27), power: h.display)
+ BrightnessOnlyDimmer(inner: DisplayServicesDimmer(osMajorVersion: unmeasuredMajor), power: h.display)
}
private func logText() -> String {
@@ -258,8 +265,8 @@ final class RefusedDarkeningTests: XCTestCase {
XCTAssertEqual(h.keyboard.sets, [0])
XCTAssertEqual(h.display.sleepRequests, 1)
let log = logText()
- XCTAssertTrue(log.contains("display darkening refused: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27"), log)
- XCTAssertTrue(log.contains("display darken on lid close skipped: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27"), log)
+ XCTAssertTrue(log.contains("display darkening refused: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)"), log)
+ XCTAssertTrue(log.contains("display darken on lid close skipped: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)"), log)
XCTAssertTrue(log.contains("keyboard backlight off (was brightness 0.5)"), log)
await actions.onOpen()
@@ -357,7 +364,7 @@ final class RefusedDarkeningTests: XCTestCase {
XCTAssertFalse(after.hasLidActions, "the live session keeps the sleep guard journaled, but no lid action is left to undo")
let error = try XCTUnwrap(m.lastError)
XCTAssertTrue(error.hasPrefix("could not restore the brightness saved before the lid closed on this macOS build. "), error)
- XCTAssertTrue(error.contains("Display brightness 0.8: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27"), error)
+ XCTAssertTrue(error.contains("Display brightness 0.8: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)"), error)
XCTAssertTrue(error.contains("Keyboard backlight 0.3: KeyboardBrightnessClient isKeyboardBuiltIn: has type encoding B@:i"), error)
XCTAssertTrue(error.contains("Set the levels with the brightness keys or Control Center"), error)
XCTAssertTrue(error.contains("the saved values stay in the journal"), error)
@@ -1120,7 +1127,7 @@ final class RefusedDarkeningTests: XCTestCase {
let incomplete = try XCTUnwrap(h.notifier.posts.first { $0.title == SessionManager.incompleteTitle }, "\(h.notifier.posts)")
XCTAssertTrue(incomplete.body.contains("could not restore keyboard backlight"), incomplete.body)
- XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were measured on macOS 26 only"), incomplete.body)
+ XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were \(measuredMajorsText)"), incomplete.body)
let after = try XCTUnwrap(try h.store.loadState())
XCTAssertEqual(after.savedKeyboardBrightness, 0.3, "the failed write keeps its entry")
XCTAssertTrue(after.displayRestoreRefused)
@@ -1144,7 +1151,7 @@ final class RefusedDarkeningTests: XCTestCase {
let incomplete = try XCTUnwrap(h.notifier.posts.first { $0.title == SessionManager.incompleteTitle }, "\(h.notifier.posts)")
XCTAssertTrue(incomplete.body.contains("could not restore audio"), incomplete.body)
- XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were measured on macOS 26 only"), incomplete.body)
+ XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were \(measuredMajorsText)"), incomplete.body)
XCTAssertEqual(m.lastError.map { incomplete.body.hasPrefix($0) }, true, "\(String(describing: m.lastError))")
}
@@ -2982,7 +2989,7 @@ final class RefusedDarkeningTests: XCTestCase {
let notes = m.darkenRefusals
XCTAssertEqual(notes.count, 2, "\(notes)")
- XCTAssertTrue(notes.first?.hasPrefix("Display: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27") == true, "\(notes)")
+ XCTAssertTrue(notes.first?.hasPrefix("Display: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)") == true, "\(notes)")
XCTAssertTrue(notes.last?.hasPrefix("Keyboard backlight: KeyboardBrightnessClient isKeyboardBuiltIn: has type encoding B@:i") == true, "\(notes)")
XCTAssertEqual(h.makeManager().darkenRefusals, [], "the harness fakes refuse nothing")
}
diff --git a/Tests/InsomniaTests/ReleaseWorkflowTests.swift b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
index bfbe4717..4bde50cc 100644
--- a/Tests/InsomniaTests/ReleaseWorkflowTests.swift
+++ b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
@@ -1,11 +1,15 @@
import Foundation
import XCTest
+@testable import Insomnia
/// Static checks on .github/workflows/*.yml: every action is pinned to a
/// commit SHA with its version alongside, and no job runs with the default
-/// token permissions. release.yml gets a few more: its triggers, that a
-/// manual run never publishes, and that only the publishing job can write.
-/// Plain text checks; the files are small and have no YAML anchors.
+/// token permissions. release.yml gets a few more: its triggers, that only a
+/// pushed v* tag publishes a stable release and only main a nightly, that the
+/// two channels never share their release flags, and that only the
+/// publishing jobs can write. Plain text checks, except that the plan job's
+/// script and the nightly job's tag step also run with a stub `gh`; the
+/// files are small and have no YAML anchors.
final class ReleaseWorkflowTests: XCTestCase {
private var workflowsDir: URL {
URL(fileURLWithPath: #filePath)
@@ -21,6 +25,59 @@ final class ReleaseWorkflowTests: XCTestCase {
private func lines(_ text: String) -> [String] { text.components(separatedBy: "\n") }
+ private func releaseWorkflow() throws -> String {
+ try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
+ }
+
+ /// release.yml's jobs by id, each with its lines up to the next job.
+ private func releaseJobs() throws -> [String: [String]] {
+ let ls = lines(try releaseWorkflow())
+ let start = try XCTUnwrap(ls.firstIndex(of: "jobs:"), "release.yml has no jobs")
+ var jobs: [String: [String]] = [:]
+ var current: String?
+ for line in ls[(start + 1)...] {
+ if line.range(of: #"^ [A-Za-z_][\w-]*:\s*$"#, options: .regularExpression) != nil {
+ let id = String(line.trimmingCharacters(in: .whitespaces).dropLast())
+ XCTAssertNil(jobs[id], "job \(id) appears twice")
+ jobs[id] = []
+ current = id
+ } else if let current {
+ jobs[current, default: []].append(line)
+ }
+ }
+ return jobs
+ }
+
+ private func job(_ id: String) throws -> [String] {
+ try XCTUnwrap(try releaseJobs()[id], "release.yml has no job \(id)")
+ }
+
+ /// The flags of the job's one `gh release create` command (comments
+ /// that name it aside), continuation lines included.
+ private func releaseCreateFlags(_ body: [String]) throws -> [String] {
+ let starts = body.indices.filter { body[$0].trimmingCharacters(in: .whitespaces).hasPrefix("gh release create ") }
+ XCTAssertEqual(starts.count, 1, "one gh release create")
+ var command: [String] = []
+ for line in body[try XCTUnwrap(starts.first)...] {
+ command.append(line)
+ if !line.hasSuffix("\\") { break }
+ }
+ return command.joined(separator: " ").split(separator: " ").map(String.init).filter { $0.hasPrefix("--") }
+ }
+
+ /// The scopes of the job's one `permissions:` block, comments dropped.
+ /// A scope added on any later line, or an inline map or write-all in
+ /// place of the block, fails the caller's comparison.
+ private func permissionScopes(_ body: [String], job id: String) throws -> [String] {
+ let keys = body.indices.filter { body[$0].hasPrefix(" permissions:") }
+ XCTAssertEqual(keys.count, 1, "one permissions key in the \(id) job")
+ let key = try XCTUnwrap(keys.first)
+ XCTAssertEqual(body[key], " permissions:", "\(id): a block, not an inline map or write-all")
+ return body[(key + 1)...]
+ .prefix { $0.hasPrefix(" ") }
+ .map { $0.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0].trimmingCharacters(in: .whitespaces) }
+ }
+
func testEveryActionIsPinnedToACommitSHAWithItsVersionNoted() throws {
let pinned = try NSRegularExpression(pattern: #"^\s*-?\s*uses:\s*[\w.-]+/[\w./-]+@[0-9a-f]{40}\s+#\s*v\d+(\.\d+)*\s*$"#)
for (name, text) in try workflows() {
@@ -65,17 +122,31 @@ final class ReleaseWorkflowTests: XCTestCase {
}
}
- func testReleaseWorkflowRunsOnVersionTagsAndManuallyButOnlyTagsPublish() throws {
- let text = try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
+ func testReleaseWorkflowTriggersAndWhatEachOnePublishes() throws {
+ let text = try releaseWorkflow()
XCTAssertTrue(text.contains("tags: ['v*']") || text.contains("tags: [\"v*\"]"), "runs on v* tags")
+ XCTAssertTrue(text.contains("\n schedule:\n") && text.contains("\n - cron: '"), "runs daily for the nightly")
XCTAssertTrue(text.contains("workflow_dispatch:"), "can be run by hand")
+ XCTAssertTrue(text.contains(" inputs:\n nightly:\n description: "), "a manual run can ask for a nightly")
+ XCTAssertTrue(text.contains(" type: boolean\n default: false\n"), "a manual run publishes nothing unless asked")
XCTAssertFalse(text.contains("pull_request"), "never runs for pull requests")
- XCTAssertTrue(text.contains("if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')"), "publishing is gated on a pushed tag, not on a manual run that happens to use a tag ref")
- XCTAssertTrue(text.contains("--signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF"), "the notes pin the verify command to this workflow and the tag")
- XCTAssertTrue(text.contains(#"echo "./install.sh --allow-unverified-origin --app ./Insomnia.app""#), "the notes' install command carries the origin opt-in install.sh needs for an ad-hoc bundle")
XCTAssertTrue(text.contains("permissions: {}"), "nothing at the top level")
- XCTAssertTrue(text.contains("--prerelease"), "ad-hoc releases are marked prerelease")
- XCTAssertTrue(text.contains("attest-build-provenance"), "build provenance is attested")
+ XCTAssertEqual(Set(try releaseJobs().keys), ["plan", "build", "stable", "nightly"])
+
+ let plan = try job("plan").joined(separator: "\n")
+ XCTAssertTrue(plan.contains(#"if [[ "$NIGHTLY_INPUT" != true ]]; then\#n printf 'channel=artifact\nbuild=true\n' >> "$GITHUB_OUTPUT""#), "a manual run without the input only builds")
+ XCTAssertTrue(plan.contains(#"if [[ "$GITHUB_REF" != refs/heads/main ]]; then"#), "a nightly from any other ref stops before building")
+ XCTAssertTrue(try job("build").contains(" if: needs.plan.outputs.build == 'true'"), "nothing is built when the plan says so")
+
+ let stable = try job("stable")
+ let nightly = try job("nightly")
+ XCTAssertTrue(stable.contains(" if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')"), "a stable release is gated on a pushed tag, not on a manual run that happens to use a tag ref")
+ XCTAssertTrue(nightly.contains(" if: needs.plan.outputs.channel == 'nightly' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"), "a nightly is gated on main and on a scheduled or manual run")
+ for (id, body) in [("stable", stable.joined(separator: "\n")), ("nightly", nightly.joined(separator: "\n"))] {
+ XCTAssertTrue(body.contains("--signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF --source-digest $GITHUB_SHA"), "\(id): the notes pin the verify command to this workflow, the ref and the commit")
+ XCTAssertTrue(body.contains(#"echo "./install.sh --allow-unverified-origin --app ./Insomnia.app""#), "\(id): the notes' install command carries the origin opt-in install.sh needs for an ad-hoc bundle")
+ XCTAssertTrue(body.contains("attest-build-provenance"), "\(id): build provenance is attested")
+ }
XCTAssertTrue(text.contains("shasum -a 256"), "a checksum file is produced")
XCTAssertTrue(text.contains("ditto -c -k --sequesterRsrc --keepParent"), "zipped the way Finder expects")
XCTAssertTrue(text.contains("CFBundleShortVersionString"), "the tag is checked against the bundle version")
@@ -83,6 +154,206 @@ final class ReleaseWorkflowTests: XCTestCase {
XCTAssertFalse(text.contains("--allow-unsigned"), "no Gatekeeper workarounds")
}
+ /// A stable release is Latest and never a prerelease. A nightly is a
+ /// prerelease, never Latest, under a new tag that names its date and
+ /// commit; the workflow creates that tag with the call that fails when
+ /// it exists, and nothing in the workflow moves, deletes or replaces a
+ /// tag, a release or an asset.
+ func testStableAndNightlyNeverShareTheirReleaseFlags() throws {
+ XCTAssertEqual(try releaseCreateFlags(try job("stable")), ["--repo", "--title", "--notes-file", "--verify-tag", "--latest"])
+ XCTAssertEqual(try releaseCreateFlags(try job("nightly")), ["--repo", "--title", "--notes-file", "--verify-tag", "--prerelease", "--latest=false"])
+ let plan = try job("plan").joined(separator: "\n")
+ XCTAssertTrue(plan.contains(#"sha12="${GITHUB_SHA:0:12}""#))
+ XCTAssertTrue(plan.contains(#"tag="nightly-$(date -u +%Y%m%d)-$sha12""#), "the nightly tag names the UTC date and the commit")
+ XCTAssertTrue(plan.contains(#"matching-refs/tags/nightly-"#), "existing nightly tags are looked up")
+ XCTAssertTrue(plan.contains(#"printf 'channel=nightly\nbuild=false\n' >> "$GITHUB_OUTPUT""#), "a commit that already has a nightly is skipped")
+ let build = try job("build").joined(separator: "\n")
+ XCTAssertTrue(build.contains(#"want="^nightly-[0-9]{8}-${GITHUB_SHA:0:12}\$""#), "the build checks the tag names the commit it built")
+ XCTAssertTrue(build.contains(#"package="Insomnia-$version-$NIGHTLY_TAG-macos""#), "a nightly zip carries its tag in its name")
+ let nightly = try job("nightly").joined(separator: "\n")
+ XCTAssertTrue(nightly.contains(#"gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$GITHUB_SHA""#), "the tag is created on the built commit, failing if it exists")
+ let text = try releaseWorkflow()
+ for absent in ["force", "PATCH", "DELETE", "--clobber", "gh release upload", "gh release edit", "gh release delete", "git push", "git tag"] {
+ XCTAssertFalse(text.contains(absent), "release.yml contains \(absent)")
+ }
+ }
+
+ /// Runs the plan job's script from release.yml, as a scheduled run on
+ /// main would, with a stub `gh` serving the nightly tags and the release
+ /// names of each case. Only a commit whose nightly tag has a release is
+ /// skipped. A tag with no release, or a tag naming this commit's first 12
+ /// characters on another commit, stops the run with no outputs, so
+ /// nothing is built. A commit with no nightly tag gets a new one.
+ func testThePlanSkipsOnlyACommitWhoseNightlyHasARelease() throws {
+ let script = try stepScript("plan", "Choose the channel, and the tag for a nightly")
+ let sha = "0123456789abcdef0123456789abcdef01234567"
+ let tag = "nightly-20261009-0123456789ab"
+ let olderRef = "refs/tags/nightly-20261008-fedcba987654 fedcba9876543210fedcba9876543210fedcba98"
+ let olderRelease = "nightly-20261008-fedcba987654"
+ // The stub prints what the script's two `gh api` calls would print
+ // after their jq filters, and fails any other call.
+ let stub = """
+ if [[ $# -eq 5 && $1 == api && $2 == --paginate && $4 == --jq ]]; then
+ case "$3" in
+ repos/krishhgg/Insomnia/git/matching-refs/tags/nightly-) exec /bin/cat "$here/refs" ;;
+ "repos/krishhgg/Insomnia/releases?per_page=100") exec /bin/cat "$here/releases" ;;
+ esac
+ fi
+ """
+ func plan(refs: [String], releases: [String]) throws -> StepResult {
+ try runStep(script, gh: stub,
+ files: ["refs": refs.map { $0 + "\n" }.joined(), "releases": releases.map { $0 + "\n" }.joined()],
+ environment: ["EVENT": "schedule", "NIGHTLY_INPUT": "", "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": sha])
+ }
+
+ var r = try plan(refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, tag])
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertEqual(r.output, "channel=nightly\nbuild=false\n", "a published nightly of this commit is skipped")
+
+ // A release whose name only starts with the tag is not its release.
+ r = try plan(refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, "\(tag)-rc"])
+ XCTAssertEqual(r.status, 1, "a tag without a release stops the run")
+ XCTAssertEqual(r.output, "", "no outputs, so nothing is built")
+ XCTAssertTrue(r.stderr.contains("tag \(tag) exists but has no release"), r.stderr)
+
+ let foreign = "0123456789ab" + String(repeating: "f", count: 28)
+ r = try plan(refs: ["refs/tags/\(tag) \(foreign)"], releases: [tag])
+ XCTAssertEqual(r.status, 1, "a tag of another commit with the same first 12 characters stops the run")
+ XCTAssertEqual(r.output, "", "no outputs, so nothing is built")
+ XCTAssertTrue(r.stderr.contains("tag \(tag) points at \(foreign), not \(sha)"), r.stderr)
+
+ // A tag whose name only starts with this commit's nightly name is not its nightly.
+ r = try plan(refs: [olderRef, "refs/tags/\(tag)-x \(sha)"], releases: ["v0.1.0", olderRelease])
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertNotNil(r.output.range(of: #"^channel=nightly\nbuild=true\ntag=nightly-[0-9]{8}-0123456789ab\n$"#, options: .regularExpression),
+ "a commit with no nightly gets a new tag: \(r.output)")
+ }
+
+ /// Runs the nightly job's last step with a stub `gh`. The tag is created
+ /// on the built commit; when it already exists, as on a rerun of this
+ /// job, it is used only if it names that commit. A tag on another commit,
+ /// or one that cannot be read, stops the job before any release exists.
+ func testTheNightlyTagIsCreatedOrReusedOnlyOnItsOwnCommit() throws {
+ let script = try stepScript("nightly", "Create the tag and the prerelease")
+ let sha = "0123456789abcdef0123456789abcdef01234567"
+ let tag = "nightly-20261009-0123456789ab"
+ let post = "api repos/krishhgg/Insomnia/git/refs -f ref=refs/tags/\(tag) -f sha=\(sha) --silent"
+ let get = "api repos/krishhgg/Insomnia/git/ref/tags/\(tag) --jq .object.sha"
+ let create = "release create \(tag) release/Insomnia-0.1.0-\(tag)-macos.zip release/SHA256SUMS --repo krishhgg/Insomnia"
+ + " --title Insomnia nightly 2026-10-09 (0123456789ab) --notes-file notes.md --verify-tag --prerelease --latest=false"
+ // Creating the ref exits with the status in create-status; reading it
+ // prints the file `at`, or fails as GitHub does when there is none.
+ let stub = """
+ case "$1 $2" in
+ "api repos/krishhgg/Insomnia/git/refs") exit "$(/bin/cat "$here/create-status")" ;;
+ "api repos/krishhgg/Insomnia/git/ref/tags/\(tag)")
+ [[ -e $here/at ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; }
+ exec /bin/cat "$here/at" ;;
+ "release create") exit 0 ;;
+ esac
+ """
+ func publish(createStatus: Int, at: String?) throws -> StepResult {
+ var files = ["release/Insomnia-0.1.0-\(tag)-macos.zip": "", "release/SHA256SUMS": "", "notes.md": "notes\n",
+ "create-status": "\(createStatus)\n"]
+ if let at { files["at"] = at + "\n" }
+ return try runStep(script, gh: stub, files: files, environment: ["GITHUB_SHA": sha, "TAG": tag])
+ }
+
+ var r = try publish(createStatus: 0, at: nil)
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertEqual(r.calls, [post, create], "a new tag on the built commit, then the prerelease")
+
+ r = try publish(createStatus: 1, at: sha)
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertEqual(r.calls, [post, get, create], "a rerun uses the tag it made on this commit")
+
+ let foreign = "0123456789ab" + String(repeating: "f", count: 28)
+ r = try publish(createStatus: 1, at: foreign)
+ XCTAssertEqual(r.status, 1)
+ XCTAssertEqual(r.calls, [post, get], "a tag on another commit stops the job before any release")
+ XCTAssertTrue(r.stderr.contains("tag \(tag) points at \(foreign), not \(sha)"), r.stderr)
+
+ r = try publish(createStatus: 1, at: nil)
+ XCTAssertNotEqual(r.status, 0)
+ XCTAssertEqual(r.calls, [post, get], "a tag that cannot be read stops the job before any release")
+ }
+
+ /// The `run: |` block of the job's step with this name, unindented.
+ private func stepScript(_ id: String, _ step: String) throws -> String {
+ let body = try job(id)
+ let names = body.indices.filter { body[$0] == " - name: \(step)" }
+ XCTAssertEqual(names.count, 1, "one \(step) step in the \(id) job")
+ let name = try XCTUnwrap(names.first)
+ let run = try XCTUnwrap(body[name...].firstIndex(of: " run: |"), "\(step) has no run block")
+ XCTAssertFalse(body[(name + 1).. StepResult {
+ let fm = FileManager.default
+ let root = fm.temporaryDirectory.appendingPathComponent("release-step-\(UUID().uuidString)", isDirectory: true)
+ let bin = root.appendingPathComponent("bin", isDirectory: true)
+ try fm.createDirectory(at: bin, withIntermediateDirectories: true)
+ addTeardownBlock { try? FileManager.default.removeItem(at: root) }
+ for (path, contents) in files {
+ let url = root.appendingPathComponent(path)
+ try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try contents.write(to: url, atomically: true, encoding: .utf8)
+ }
+ let gh = bin.appendingPathComponent("gh")
+ try """
+ #!/bin/bash
+ here="$(cd "$(dirname "$0")/.." && pwd)"
+ echo "$*" >> "$here/calls"
+ \(body)
+ echo "stub gh: unexpected call: $*" >&2
+ exit 99
+
+ """.write(to: gh, atomically: true, encoding: .utf8)
+ try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: gh.path)
+ let scriptURL = root.appendingPathComponent("step.sh")
+ try script.write(to: scriptURL, atomically: true, encoding: .utf8)
+ let outputURL = root.appendingPathComponent("output")
+ let errURL = root.appendingPathComponent("stderr")
+ for url in [outputURL, errURL, root.appendingPathComponent("calls")] { fm.createFile(atPath: url.path, contents: nil) }
+
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: "/bin/bash")
+ p.arguments = ["-e", scriptURL.path]
+ p.currentDirectoryURL = root
+ p.environment = environment.merging([
+ "PATH": "\(bin.path):/usr/bin:/bin", "TMPDIR": NSTemporaryDirectory(),
+ "GITHUB_REPOSITORY": "krishhgg/Insomnia", "GITHUB_OUTPUT": outputURL.path,
+ ]) { _, fixed in fixed }
+ // Capture to files rather than pipes: nothing to drain, nothing to deadlock.
+ let err = try FileHandle(forWritingTo: errURL)
+ defer { try? err.close() }
+ p.standardOutput = FileHandle.nullDevice
+ p.standardError = err
+ let childExit = ProcessExit(p)
+ try p.run()
+ childExit.wait()
+ let read = { (name: String) in (try? String(contentsOf: root.appendingPathComponent(name), encoding: .utf8)) ?? "" }
+ return StepResult(status: p.terminationStatus, output: read("output"), stderr: read("stderr"),
+ calls: read("calls").split(separator: "\n").map(String.init))
+ }
+
/// Releases are ad-hoc signed and not notarized: the workflow reads no
/// secret, imports no certificate, notarizes nothing, build-app.sh has
/// no Developer ID path (no signing identity from the environment, no
@@ -92,7 +363,7 @@ final class ReleaseWorkflowTests: XCTestCase {
/// testInstallFromPrebuiltAppNeedsTheOptInWhateverItsSignatureNames
/// checks the behaviour.
func testTheReleaseIsAdHocSignedWithNoSigningOrNotarizationStep() throws {
- let text = try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
+ let text = try releaseWorkflow()
for absent in ["secrets.", "security ", "create-keychain", "notarytool", "stapler", "INSOMNIA_SIGN", "INSOMNIA_NOTARY"] {
XCTAssertFalse(text.contains(absent), "release.yml contains \(absent)")
}
@@ -108,35 +379,22 @@ final class ReleaseWorkflowTests: XCTestCase {
}
}
- /// The build job has read-only access; only the job
- /// that publishes may write, and only what publishing needs.
- func testOnlyTheReleaseJobMayWrite() throws {
- let text = try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
- let ls = lines(text)
- let buildStart = try XCTUnwrap(ls.firstIndex(of: " build:"))
- let releaseStart = try XCTUnwrap(ls.firstIndex(of: " release:"))
- XCTAssertLessThan(buildStart, releaseStart)
- let buildLines = Array(ls[buildStart..` for a stable release or `refs/heads/main` for a nightly",
+ "--source-ref [ --source-digest ]",
+ "If the release you picked is `v0.1.0`"] {
+ XCTAssertTrue(guide.contains(phrase), "docs/install.md lacks: \(phrase)")
+ }
+ }
+
+ private var repoRoot: URL { workflowsDir.deletingLastPathComponent().deletingLastPathComponent() }
+
+ private func readmeInstallSection() throws -> String {
+ let readme = try String(contentsOf: repoRoot.appendingPathComponent("README.md"), encoding: .utf8)
let install = try XCTUnwrap(readme.range(of: "\n## Install\n"), "README has no Install section")
let end = readme.range(of: "\n## ", range: install.upperBound.. @insomnia-nudge on
+```
+
+Mark a dedicated, disposable agent pane, not one you type in, because pending
+text is opaque to Insomnia. Enter is off by default, so the word is typed and
+nothing submits it. Turn on "Press Enter after continue" in Settings to submit
+it, knowing that Enter also submits anything already typed in that pane. The
+option must be on the pane itself (`-p`). One set on the session or window
+does not count. Pane options need tmux 3.0 or later. Ending a session cancels
+pending automation but cannot retract keystrokes already sent.
+
+## Chrome, Chromium and Arc throttling
+
+Chromium browsers can throttle windows macOS considers occluded, including
+when the lid is closed. Insomnia detects supported running browsers missing
+`--disable-backgrounding-occluded-windows` or `--disable-renderer-backgrounding`
+and offers **Relaunch [browser] unthrottled** in the right-click menu. The item
+asks first, because the browser is quit and its windows and tabs come back only
+if it is set to reopen them on startup. If the browser has quit by the time you
+confirm, nothing is quit or launched and a notification says so. Insomnia reads the browser's profile
+arguments before quitting and carries them over. If it cannot read them, cannot
+read the kernel's start time that ties them to the browser, or the browser
+quits on its own while they are read, it quits nothing and says so. If the
+browser has not quit after 10 s, nothing is launched, and a notification says
+so: a second copy beside the first would be worse than a throttled one. The
+quit request stands, so a browser that closes later has to be opened again by
+hand. After `open` returns, Insomnia waits up to 5 s for the browser to show up
+as running and notifies if it does not. Each of these reasons also stays in the
+right-click menu as a warning line, one per browser, until that browser's next
+relaunch or the next session, so it is there even with notifications off. A
+relaunch that ends after its session ended, or after a newer relaunch of the
+same browser started, reports nothing. This is not a guarantee that every web
+app will keep working while the lid is closed.
+
+## Configuration and privacy
+
+Configuration lives in `~/Library/Application Support/Insomnia/config.json`.
+Use Settings for the app's controls; [Config.swift](../Sources/Insomnia/Model/Config.swift)
+defines the full configuration and defaults. Local logs can contain SSIDs,
+process metadata, and tmux targets. Check them before sharing publicly.
+Lines the app writes to `insomnia.log` also go to the unified log with their
+bodies marked private, so `log show` and other local programs see ``
+in place of the text unless private data logging is enabled on the Mac. The
+backstop's lines go only to `insomnia.log`, which keeps the full text of both.
+The files in Application Support/Insomnia and Logs/Insomnia (config, session,
+journal, recovery lock, the record of a power command left running, the two
+logs) are owner-only, mode 0600 with those two directories 0700, and one left
+looser by an older build is tightened the next time the app or the backstop
+opens it. Insomnia sets only these modes and
+leaves any access control list (ACL) on these files and folders as it is, so
+an ACL someone added, or one inherited from a parent folder, can still give
+another account access (`ls -le` shows it). The LaunchAgent plist and the installed
+scripts hold no private data and keep the modes the installer gives them.
+`insomnia.log` and `handoffs.log` are capped at 1 MiB: a
+log past the cap is renamed to `insomnia.log.1` or `handoffs.log.1`,
+replacing the previous copy, and a new file starts. The cap does not apply
+to a log you replace with a symlink. Insomnia writes through the link and
+never rotates it, since the rename would move the link and not the file it
+points to, and it logs that once. You set up the link, so trimming the file
+it points to is up to you.
+
+`INSOMNIA_HOME` relocates app support files, logs, and LaunchAgents for testing.
+It is **not an installation sandbox**: installation/removal also involves the
+app bundle and sudoers rule. The installer refuses a relocated home. See
+[Paths.swift](../Sources/Insomnia/Store/Paths.swift) for the layout.
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 00000000..5a24da35
--- /dev/null
+++ b/docs/install.md
@@ -0,0 +1,272 @@
+# Install, upgrade and uninstall
+
+The [README](../README.md#install) has the short version. This page has every
+step and what each one checks. A coding agent can follow
+[skills/install-insomnia](../skills/install-insomnia/SKILL.md), which uses the
+steps on this page.
+
+Requires **macOS 26 or later on an Apple Silicon Mac**.
+
+## Channels
+
+Insomnia is published on two channels on the
+[releases page](https://github.com/krishhgg/Insomnia/releases):
+
+- Stable: the release GitHub marks Latest, tagged `v`. Use this one
+ unless you want to try changes that are not in a stable release yet.
+- Nightly: prereleases tagged `nightly--`, built automatically
+ from `main` once a day when it has new commits. Newest code, experimental,
+ and possibly broken. A nightly is never marked Latest.
+
+## v0.1.0 is different
+
+Each release's notes give the exact commands for that release. One release
+differs from the steps below: `v0.1.0`, the first stable release, was packaged
+by hand from the maintainer's installed app, not by the Release workflow. It
+has no attestation, and its zip installs with
+`./scripts/install.sh --app ./Insomnia.app`. If the release you picked is
+`v0.1.0` (its zip is `Insomnia-0.1.0-macos-arm64.zip`), follow the install
+steps in its release notes instead of steps 2 and 3.
+
+The rest of this page describes releases built by the Release workflow and
+builds of the current source, not `v0.1.0`. The `README.md` in the `v0.1.0`
+zip describes what that release installs, how its recovery works and its
+limits. In particular, the installer's version check, the recovery script
+sealed inside the app and the code requirement the recovery agent pins,
+described below, are not in `v0.1.0`.
+
+## Install a release yourself
+
+You need `gh`, the [GitHub CLI](https://cli.github.com).
+
+1. Download the zip and `SHA256SUMS` of the release you chose into an empty
+ folder, from the releases page or with `gh`. For the stable release, the
+ one marked Latest:
+
+ ```bash
+ gh release download --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+ ```
+
+ For the newest nightly, paste this whole block. It reads every page of
+ the release list and picks the most recently published nightly. It
+ downloads nothing, and returns an error, when it cannot read the list,
+ when no nightly has been published yet, or when the tag it picked is not
+ in the `nightly--<12-character commit>` form. It never falls
+ back to the stable release.
+
+ ```bash
+ insomnia_nightly() {
+ local list tag
+ list="$(gh api --paginate 'repos/krishhgg/Insomnia/releases?per_page=100' \
+ --jq '.[] | select(.prerelease and (.draft | not) and (.tag_name | startswith("nightly-")))
+ | "\(.published_at) \(.tag_name)"')" ||
+ { echo "Could not read the release list. Nothing was downloaded." >&2; return 1; }
+ tag="$(printf '%s\n' "$list" | sort | tail -n 1)"
+ tag="${tag#* }"
+ if [ -z "$tag" ]; then
+ echo "No nightly has been published yet. Nothing was downloaded." >&2
+ echo "Wait for the next daily build, or build from source." >&2
+ return 1
+ fi
+ if ! printf '%s\n' "$tag" | grep -Eqx 'nightly-[0-9]{8}-[0-9a-f]{12}'; then
+ printf 'Unexpected nightly tag: %s. Nothing was downloaded.\n' "$tag" >&2
+ return 1
+ fi
+ gh release download "$tag" --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+ }
+ insomnia_nightly
+ ```
+
+ If the releases page has no release yet, build from source (below).
+2. Verify the download. `` is the zip's file name. `[` is
+ `refs/tags/v]` for a stable release or `refs/heads/main` for a
+ nightly. `` is the full 40-character commit the release was built
+ from: its release notes name it after "Built from" and carry this command
+ filled in. A nightly's tag ends with the first 12 characters of it.
+
+ ```bash
+ shasum -a 256 -c SHA256SUMS
+ gh attestation verify -R krishhgg/Insomnia \
+ --signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
+ --source-ref [ --source-digest ]
+ ```
+
+ The first command checks the zip against the `SHA256SUMS` downloaded with
+ it. The second checks that this repository's Release workflow built this
+ exact zip from that commit for that ref, so a nightly cannot pass for a
+ stable release. Every nightly is built for `refs/heads/main`, so only
+ `--source-digest` keeps another nightly's zip from passing: do not leave
+ it out, and do not shorten the commit to the 12 characters in the tag.
+
+3. Unzip and run the installer that comes in the zip:
+
+ ```bash
+ ditto -x -k .
+ cd
+ ./install.sh --allow-unverified-origin --app ./Insomnia.app
+ open "$HOME/Applications/Insomnia.app"
+ ```
+
+ Releases are ad-hoc signed and not notarized. The installer can check that
+ the bundle is intact but not who made it, so it refuses to install without
+ `--allow-unverified-origin`, which says you ran the two commands in step 2.
+ macOS blocks the first launch of a downloaded copy until you allow it in
+ System Settings > Privacy & Security.
+
+The installer checks the bundle's signature, identifier and version before it
+asks for anything (the `v0.1.0` installer checks the signature and
+identifier, not the version). It then installs the app and a background recovery agent,
+and asks for administrator access to install a narrowly scoped sudoers rule. It grants
+**your user account**, not just Insomnia, passwordless access to four
+power-setting commands. Review that permission before installing.
+
+Release zips are built for arm64 only, and their `install.sh --app` stops on
+an Intel Mac. On Intel, building from source (below) is the only option, and
+it is untested there.
+
+## Build from source
+
+Requires **Xcode with Swift 6.2 or later**. Clone the latest stable tag, or a
+nightly tag for the newest code, rather than `main`. If the releases page has
+no release yet, leave out `--branch v` to build `main`:
+
+```bash
+git clone --branch v --depth 1 https://github.com/krishhgg/Insomnia.git
+cd Insomnia
+./scripts/install.sh
+open "$HOME/Applications/Insomnia.app"
+```
+
+`scripts/install.sh` builds the same bundle the release workflow builds
+(`scripts/build-app.sh`), ad-hoc signed, and installs it the same way. It
+builds only when it runs from a checkout's `scripts` folder, with
+`Package.swift` one level up, and then runs the `build-app.sh` beside it. The
+`install.sh` from a release zip stops and asks for `--app` instead, even when
+a `build-app.sh` was added to its folder after unpacking. A checkout of
+`v0.1.0` has the installer of that release; its README describes it.
+[docs/releasing.md](releasing.md) describes the release pipeline.
+
+## What gets installed
+
+This describes releases built by the Release workflow and builds of the
+current source. `v0.1.0` installs an older layout. Its app has no
+`backstop.sh` inside it: its installer copies `backstop.sh` into
+`~/Library/Application Support/Insomnia/`, and its recovery agent runs that
+copy with `/bin/bash` without checking any code signature. The sealed script,
+the pinned code requirement and the refusal of an edited or re-signed bundle
+described below do not apply to it, and neither does the upgrade procedure.
+Its sudoers rule grants the same four commands. The `README.md` in its zip
+describes what it installs and its limits.
+
+| Location | Purpose |
+| --- | --- |
+| `~/Applications/Insomnia.app` | The menu bar app, with `backstop.sh` sealed inside it at `Contents/Resources` |
+| `~/Library/Application Support/Insomnia/` | Configuration and the session/recovery journals |
+| `~/Library/LaunchAgents/com.insomnia.backstop.plist` | Per-user recovery agent: verifies the app's code signature, then runs the sealed `backstop.sh` |
+| `~/Library/Logs/Insomnia/` | `insomnia.log` and `handoffs.log`, each capped at 1 MiB with one older copy kept as `.1`, unless you replace it with a symlink |
+| `/etc/sudoers.d/insomnia` | Permission for the four commands below |
+
+```text
+/usr/bin/pmset -a disablesleep 1
+/usr/bin/pmset -a disablesleep 0
+/usr/bin/pmset -b lowpowermode 1
+/usr/bin/pmset -b lowpowermode 0
+```
+
+The grant is available to other processes running as your user. Insomnia is not
+sandboxed. The app, scripts, and journals are local; hotspot passwords use the
+login Keychain, not the configuration file.
+
+The recovery agent runs at login and every 60 seconds. Its command line pins
+the installed bundle's code requirement (for an ad-hoc build, the cdhash of
+that build) and runs `codesign --verify --strict` against it before executing
+the `backstop.sh` sealed inside the bundle. An edited bundle or script fails
+that check: the agent writes one line to `insomnia.log` and runs nothing until
+you reinstall. So no other account can edit it, the installer removes group
+and other write permission and every ACL from the bundle it installs. The
+signature covers neither, so the bundle still verifies, and extended
+attributes such as a download's quarantine flag are kept. No executable is
+kept in a writable support directory. The plist in `~/Library/LaunchAgents`
+is still a per-user file that any program running as you can edit, like
+every LaunchAgent; the app rewrites it at the next session start when it does
+not match, which is a repair, not a tamper check.
+
+What the app pins is the requirement of the code it is itself running, read
+through the Security framework after checking that the bundle on disk is still
+that code and still passes the agent's check. A bundle whose sealed script was
+edited, or that was re-signed under the running app, is refused rather than
+pinned: the app does not start a session, or reports the end as incomplete,
+and names the reason, until you reinstall. With ad-hoc signatures this guards
+against accidental edits and against the app relaying a tampered bundle into
+the agent, not against a process running as you: that process can edit the
+plist, load its own agent, quit the app and launch a replacement, and run the
+four `pmset` commands itself.
+
+## Upgrades
+
+An upgrade asks the running app to quit and stops if it refuses. The new
+bundle is built in a staging directory next to the app and moved into place in
+the same step that replaces the recovery agent. That step starts only after
+`launchctl print` confirms the previous agent is unloaded; otherwise nothing is
+replaced. If the new agent cannot be loaded, or its plist cannot be saved, the
+installer unloads it, waits for `launchctl print` to confirm that, and puts the
+previous bundle back, so the loaded agent always matches the installed app. A
+bundle that cannot be moved during that step is handled the same way: the
+previous bundle goes back and its agent is loaded again. If the previous bundle
+itself cannot be moved back, nothing is deleted: it stays at
+`~/Applications/.Insomnia.app.previous`, the installer prints the two commands
+that put it back and load its agent, and until then the agent finds no app, so
+run them or rerun the installer before you log out. If
+the unload is not confirmed, the new bundle stays with the agent that pins it
+and the installer asks you to rerun it. After that, or after an install killed
+in the middle of that step, the next run keeps whichever bundle the agent's
+plist on disk pins. It does that only after its own recovery step succeeds:
+while recovery is unresolved, an agent the earlier run left loaded may be the
+one retrying it, so the installer stops without unloading that agent or
+moving either bundle. Once recovery succeeds, it unloads that agent, moves the
+bundle back and loads the plist on disk again, and it stops if `launchctl
+print` does not confirm the unload or the reload. Unresolved recovery prevents
+replacing either; follow the reported instructions before retrying. The
+installer checks the sudoers rule again once it holds the recovery lock, and
+stops if the rule is gone, as after an `uninstall.sh` that took the lock first.
+Each call it makes to `sudo`, `pgrep`, `launchctl` or `codesign` while it
+holds the lock has a 30 s limit, which a supervising process enforces even
+if the installer is killed meanwhile. The call keeps the lock until it has
+exited or been stopped, so no `launchctl bootout` it started is still
+running once the lock is released. A call that does not answer in time gets SIGTERM, then SIGKILL
+one to two seconds later, and the install stops, so the lock is released and the app
+and the agent's backstop can take it again to undo a session. `sudo` only
+ever gets SIGTERM: one that ignores it keeps the lock until it ends, and the
+installer prints its pid.
+
+## Uninstall
+
+From your checkout:
+
+```bash
+./scripts/uninstall.sh
+# Also remove Insomnia-owned configuration and logs:
+./scripts/uninstall.sh --purge
+```
+
+From the unpacked release zip, run `./uninstall.sh` (or `./uninstall.sh
+--purge`) in its folder (`Insomnia--macos`, or the nightly name). A
+checkout's uninstaller (in `scripts`, with `Package.swift` one level up) runs
+the `backstop.sh` beside it. Anywhere else, such as the zip's folder, the
+uninstaller runs only the copy sealed in the installed app, after `codesign
+--verify --strict` passes on the app, and stops without removing anything
+when there is none. The zip has no `backstop.sh`, so one added beside its
+uninstaller is not run. Neither looks in the folder above its own.
+
+The `v0.1.0` zip is different: run `./scripts/uninstall.sh` in its unpacked
+`Insomnia-0.1.0-macos-arm64` folder, as its release notes say. That older
+uninstaller runs the `scripts/backstop.sh` in the same folder.
+
+The uninstaller requests cleanup before removing the app, agent, and sudoers
+rule. If recovery is incomplete or the app refuses to quit, it stops; resolve
+the reported problem and retry. With the app it removes what an interrupted
+install left beside it: `~/Applications/.Insomnia.app.previous`, and
+`.Insomnia.app.staging.*` directories of installs that are no longer running.
+Nothing else in `~/Applications` is touched. Purge removes owned files, not arbitrary
+directory contents. A small shared lock file is retained to keep concurrent
+recovery operations coordinated.
diff --git a/docs/lid-close.md b/docs/lid-close.md
new file mode 100644
index 00000000..a65a7d82
--- /dev/null
+++ b/docs/lid-close.md
@@ -0,0 +1,130 @@
+# What happens when the lid closes
+
+The [README](../README.md#what-happens-when-the-lid-closes) lists the
+defaults. This page has the details.
+
+During a session, Insomnia turns the display and keyboard backlight off
+(saving their brightness first), pauses the apps on the freeze list (and, if
+you opt in, every other Dock app that is not an agent app), checks whether
+Docker Desktop is idle before pausing it, and saves then mutes audio.
+Reopening the lid attempts to undo those lid actions. If the lid opens
+while Insomnia is still checking Docker, Docker is left running and the undo
+starts right away. **The timer keeps counting down while the lid is
+closed**; only its on-screen redraw pauses, also for a session started with
+the lid already closed.
+
+## Display and keyboard backlight
+
+The display step exists because the sleep guard stops macOS from doing it:
+with sleep disabled, closing the lid no longer turns the panel or the keys off
+by itself. Insomnia sets both to zero and restores them when the lid opens.
+Both go through private macOS frameworks. The display calls run only on a
+macOS major version they were measured on (26 and 27). The keyboard calls run only
+while the private keyboard class has the method signatures measured on 26,
+on whatever version. If either check refuses a device, Insomnia leaves it
+alone and Settings says why under the toggle. A level saved before an update
+that the check now refuses stays saved for a version that can restore it, and
+the menu says to set it with the brightness keys meanwhile. That version leaves
+a level you set by hand alone, and decides only on a reading taken with the
+display awake and the keys not dimmed. Once Insomnia's own Low Power Mode has
+been on over the saved display level, it leaves that level undecided until the
+Mac restarts, through relaunches of the app, and until then a lid close leaves
+that display lit and only asks it to sleep. The same goes when the recovery
+agent switches that mode off before Insomnia starts again, and when Insomnia
+switches off a mode still claimed from before a restart. That mode may read
+off then, yet it may have gone off only a moment before, so the level waits
+for the next restart even when the mode has been off for days. Once it has seen
+that display lit above zero, it never writes the saved level over a zero you
+set by hand, also after a relaunch or a restart. It cannot tell that zero
+from one auto-brightness left under a closing lid, so the saved level stays
+undecided, with nothing written, until you raise the display above zero. If Insomnia cannot record that it saw the display lit, or that the
+saved level is settled, Quit waits until it can.
+The display comes back to the brightness sampled while the lid was open, not
+the reading at the moment of closing (auto-brightness has already dimmed the
+panel under the closing lid by then, and Low Power Mode rescales it), and if
+Insomnia's own Low Power Mode was on while the lid was closed the value is
+written once more when the mode ends. If Insomnia is not running when you open
+the lid, press the brightness-up key.
+
+## Defaults
+
+The defaults are worth knowing:
+
+- **Selected apps:** Slack, WhatsApp, and Discord are on the freeze list.
+ Configured agent apps are excluded from this ordinary list.
+- **Every other app:** "Freeze every other app while the lid is closed" is
+ off, so a fresh install pauses the freeze list only. Turn it on to also pause
+ every Dock app that is not an agent app, an Apple app, Docker Desktop or a
+ built-in protected app (editors, terminals, browsers, AI apps, password
+ managers, local databases, Tailscale, local model servers; JetBrains IDEs by
+ bundle-id prefix), so only agents keep running with the lid shut. Menu-bar
+ apps are never picked up automatically; add them to the freeze list if you
+ want them paused. Settings shows a "Would freeze now" line listing what the
+ automatic scope would pause at that moment.
+- **Meeting, recording and dictation apps:** never frozen by "Freeze every
+ other app", with their helper apps: Zoom, Microsoft Teams (new and classic),
+ Webex (and the older Webex Meetings app), FaceTime, Wispr Flow, Granola,
+ Otter, OBS and Loom. Freezing one ends the call, the recording or the
+ meeting notes when the lid closes. Putting one on the freeze list by hand
+ still freezes it, except FaceTime, which is an Apple app.
+- **Docker rule:** off. Turn it on to pause Docker Desktop on lid close when
+ no container is running. The local Desktop socket is asked once to pick
+ Docker up and once more right before the pause; a busy answer, a failed
+ `docker ps` or a timeout at either point leaves Docker running. A container
+ that starts between the second check and the pause is still paused with
+ Desktop, so leave the rule off for Docker workloads an unexpected pause
+ would hurt.
+- **Mute on close:** on, so sound stops when the lid closes. Lid open
+ restores each output that was muted, even if another one is in use by then.
+ An output that is not connected stays muted until it reconnects: Insomnia
+ restores it then if it is running, or at the next launch. Ending or
+ quitting a session does not wait for it. The end notification and the menu
+ name it, and the menu's "Stop waiting for " leaves it as it is. A
+ restore that fails on a connected output is retried by Insomnia while it
+ runs, and at the next launch.
+- **Microphone:** on Mac laptops with Apple silicon or a T2 chip, closing the
+ lid disconnects the built-in microphone in hardware. Recording a meeting
+ with the lid closed needs AirPods or an external mic; Settings says the same
+ next to the lid-close options.
+- **Display and keyboard backlight:** on ("Turn off the display and keyboard
+ backlight" in Settings). Both values are saved to the journal before they
+ are changed.
+- **Low Power Mode while the lid is closed:** on. With sleep disabled a closed
+ Mac otherwise keeps running at full speed; the mode is switched off again
+ when the lid opens, unless a battery or thermal rule still wants it.
+- **Battery rules:** below 40% on battery, request Low Power Mode; below 10%,
+ end the session. A battery that is present but cannot be read on two
+ consecutive reads while on battery also ends the session, with a
+ notification saying why: the floor cannot be applied to a level nobody can
+ read. One failed read is tolerated, and the level is re-read every 30 s
+ until it is readable again. A desktop has no battery and no floor. Serious
+ thermal state requests Low Power Mode; critical thermal state ends the
+ session. These rules require the app to be running.
+ Setting the end floor to 0 turns the battery end off. Otherwise the end
+ floor stays below the Low Power Mode floor. The Settings steppers move the
+ other floor when the two would cross, and a hand-edited `config.json` with
+ the floors out of order is corrected at launch, and logged, by raising the
+ Low Power Mode floor.
+
+## Upgrading from an earlier build
+
+Upgrading from an earlier build changes two of these once. On the first
+launch of this version, a config.json saved by an earlier build gets "Freeze
+every other app" turned off and "Mute audio on lid close" turned on, and
+Insomnia posts a notification naming what changed. Settings shows the same
+line at the top of Lid-close actions until you dismiss it, for anyone with
+notifications off. The toggles are right below it. config.json records that
+the update ran (`lidCloseDefaultsApplied`), so a setting you turn back stays
+the way you set it. A fresh install starts with the new defaults and no
+notice.
+
+## Testing lid actions without closing the lid
+
+To exercise the lid actions without closing the lid, run
+`scripts/simulate-lid.sh closed` and then `scripts/simulate-lid.sh open` during
+a session; the app runs the same actions it would on a real lid event. Only a
+build with the file watcher compiled in reads that trigger: a debug build, or
+a release build installed with `INSOMNIA_LID_SIMULATION=1 ./scripts/install.sh`.
+A normal install has no watcher, so no program running as your user can replay
+the lid actions by writing a file. A build that has it logs "Lid simulation
+build" at launch and shows the same line in the status menu and in Settings.
diff --git a/docs/recovery.md b/docs/recovery.md
new file mode 100644
index 00000000..aca8f94f
--- /dev/null
+++ b/docs/recovery.md
@@ -0,0 +1,120 @@
+# How recovery works
+
+The [README](../README.md#how-recovery-works) has the short version. This page
+has every case.
+
+Insomnia records pending changes in a recovery journal. On session end, the app
+attempts to undo them. An independent `launchd` agent checks every minute and
+can attempt recovery after the app exits unexpectedly, once the saved deadline
+has passed. It leaves a valid, unexpired session alone.
+
+The app and backstop use the same lock so they do not restore and rewrite the
+journal over one another. Failed restoration keeps the relevant entries;
+unreadable journals are preserved instead of treated as clean. A session file
+that does not parse counts as expired and is renamed to
+`session.json.unreadable-` beside it, never deleting or overwriting
+anything: the app does this at launch, before restoring whatever the journal
+holds, and says where the file went; the agent does it once the journal is
+clean. A session file that cannot be read at all (permissions, or not a
+regular file, which is never opened) also counts as expired, since its end
+time is unknown: the journal is restored and the file is renamed the same
+way without being opened, so a later launch cannot resume a session that
+was treated as ended. The app says where it went. If the rename fails, the
+app keeps trying it and will not quit until the file is gone.
+`uninstall.sh --purge` removes the renamed copies that are regular files;
+without `--purge` they stay.
+
+**Recovery is not "everything always gets undone."** The backstop does not
+monitor battery or temperature. Saved audio needs the app to reopen, and
+unconfirmed process freezes may need manual inspection. If a warning remains,
+resolve it before leaving the Mac unattended. Real-machine crash, reboot, and
+installation scenarios still need [release validation](release-validation.md).
+
+## Recovery limits and manual attention
+
+- **Process ownership:** automatic resume checks the recorded process start
+ time and boot session. The app journals each pid before it sends SIGSTOP,
+ and sends nothing when that write fails. The identity is added only after
+ the kernel confirms Insomnia's own stop, so a process somebody else had
+ stopped is never resumed. If the confirming write fails, the app sends
+ SIGCONT at once to each pid it just stopped whose identity still matches,
+ even one that does not show as stopped yet: SIGCONT also cancels a stop
+ that is still pending. It then drops those entries from the journal,
+ shows the failure in the status menu, and stops counting them as frozen
+ even if the disk refuses that write too. If the app dies between the stop and that write,
+ the stopped pids stay journaled without identity, like entries from builds
+ that recorded the pid alone, and are not automatically resumed while
+ stopped. Verify the live process and whether it should be resumed; never
+ blindly signal a PID from an old log.
+- **Identity is not an atomic guarantee:** the app checks start time to the
+ microsecond, and the backstop asks the installed app binary
+ (`Insomnia --resume-frozen`) to do the same check and send the signal for
+ every entry that records microseconds, all such entries in one call with a
+ 30-second limit. The entries go to the binary on standard input, so a long
+ journal cannot exceed the argument size limit. The backstop never signals those entries itself. It keeps
+ them when the binary is missing, does not finish in time, or answers
+ anything but one expected line per entry. It runs the binary only when the
+ installed bundle declares `InsomniaResumeFrozenVersion` in its
+ `Info.plist`, so it never starts an older build. The binary holds the
+ recovery lock while it can still send a signal and ends itself after the
+ same limit, so a backstop run that is killed mid-call leaves no helper
+ that could act later without the lock. `uninstall.sh` uses the backstop
+ installed with an app that does not declare that version. With no such
+ copy, the checkout's backstop keeps those entries and uninstall stops
+ before removing anything. Entries written by builds before
+ microseconds were recorded keep the one-second `ps` comparison in the
+ shell. A lookup and a signal are still separate operations, one pid at a
+ time.
+- **Stuck power commands:** a `sudo pmset` that has not finished after 20 s
+ is sent SIGTERM, never SIGKILL: killing sudo could leave a root pmset
+ changing power settings after the journal has moved on. If it is still
+ running 3 s later the transaction stops where it is, as the backstop's
+ does: nothing else is undone, the journal keeps its entries, and the
+ recovery lock stays held until the command exits. The command holds the
+ lock itself (its stdin is a descriptor on the lock file), so if Insomnia
+ crashes or is force-quit meanwhile, the backstop still waits for the
+ command instead of running an undo the command would then override. A
+ notification and a menu warning give the pid and `sudo kill `; the
+ warning goes away when the command exits. The pid is also written to
+ `unfinished-command.json` with the command's start time and boot
+ session. A relaunch that finds the lock busy names the command in the
+ menu. It gives the pid and `sudo kill`, in one notification as well,
+ only while that pid still has the recorded start time and boot session;
+ otherwise it says the command has exited, since the pid may now belong
+ to another process. The relaunch tries again 30 s after each refusal.
+ Once the command has exited, it resumes a session that has not expired,
+ with its battery floors, and checks Low Power Mode the way it does after
+ its own command exits, described below. A session the user starts
+ before that next try gets the same check. Until the command exits,
+ Insomnia refuses to quit or start a session, and records any end or lid
+ event it refuses. A
+ `disablesleep 0` or `lowpowermode 0` that exits 0 counts as done: its
+ journal entry is cleared before the lock is released, and the command
+ is not run again. If that journal write fails, the menu says so and the
+ undo runs again; the line goes once a later write clears the entry. Any
+ other exit counts as a failure. Then a pending end runs again.
+ Otherwise Insomnia reads Low Power Mode. If it reads off, Insomnia runs
+ its own `lowpowermode 0` and forgets the mode only once that succeeds.
+ Then it replays a refused lid event, after waiting out the 2 s lid
+ debounce, and runs the floor rules again. If the mode cannot be read or
+ switched off, or the journal cannot be written, it tries again every
+ 30 s while the session lasts.
+- **Audio:** the backstop preserves volume/mute entries but cannot restore
+ CoreAudio. Reopen the app for recovery. An output device that is not
+ connected keeps its entry until it reconnects, and uninstall stops while
+ one is waiting: connect it and open Insomnia, or choose "Stop waiting for
+ " in the menu.
+- **Sleep disabled by something else:** at launch, with no session and no
+ journal entry, a `SleepDisabled 1` in `pmset -g` is left alone: Insomnia
+ did not set it and only its owner should undo it. The menu shows a warning
+ and a notification gives the command, `sudo pmset -a disablesleep 0`.
+ Ending an Insomnia session sets it to 0 whoever set it.
+- **Low Power Mode:** Insomnia checks the existing setting so it does not
+ claim ownership of an already-enabled preference.
+- **App Nap:** off by default. When the setting is on, Insomnia journals each
+ agent app's previous `NSAppSleepDisabled` value before writing it and puts
+ it back at session end, in the backstop, and in uninstall. Values an older
+ build wrote without a record are not guessed at: uninstall prints the
+ `defaults delete` command for each one and continues.
+- **Uninstall:** refuses to remove recovery machinery while unresolved changes
+ remain. A failed uninstall is not confirmation that power settings are normal.
diff --git a/docs/release-validation.md b/docs/release-validation.md
index a7458939..8d354a3e 100644
--- a/docs/release-validation.md
+++ b/docs/release-validation.md
@@ -194,7 +194,8 @@ is performed. Do not replace "not run" with "passed" based on source review.
| Lid-close/open and safe recovery of explicitly selected test processes | Not run |
| Lid-close display/keyboard darkening and restore | Not run |
| Darkening still runs under the private-call guards on macOS 26 (close the lid during a session: the log shows "display darkened" and "keyboard backlight off" and no "refused" line; Settings shows no note under the darken toggle) | Not run |
-| Darkening refused on an unmeasured macOS version (a macOS major other than 26, or a `KeyboardBrightnessClient` whose methods changed: the log shows the "refused" line once and "skipped" at lid close, nothing is journaled for that device, and Settings names the reason under the darken toggle; with both refused the log shows "display sleep not requested" and the panel is lit when the lid opens; a level saved by the earlier build stays in state.json with `displayRestoreRefused` or `keyboardRestoreRefused` after the first open or launch, the menu says it could not be restored on this macOS build and names each saved level, Settings shows the saved level under the darken toggle, no "Restore incomplete" follows, and the backstop log does not fail for it) | Not run |
+| Darkening refused on an unmeasured macOS version (a macOS major other than 26 or 27, or a `KeyboardBrightnessClient` whose methods changed: the log shows the "refused" line once and "skipped" at lid close, nothing is journaled for that device, and Settings names the reason under the darken toggle; with both refused the log shows "display sleep not requested" and the panel is lit when the lid opens; a level saved by the earlier build stays in state.json with `displayRestoreRefused` or `keyboardRestoreRefused` after the first open or launch, the menu says it could not be restored on this macOS build and names each saved level, Settings shows the saved level under the darken toggle, no "Restore incomplete" follows, and the backstop log does not fail for it) | Not run |
+| Low Power Mode on macOS 27, where `pmset -g custom` prints `powermode` and no `lowpowermode` (a session on battery with the lid closed and "Low Power Mode while the lid is closed" on, or below the Low Power floor): the log has no "could not read low power mode" line, `pmset -g custom` shows `powermode 1` under Battery Power while the mode is on, and the session end leaves it at `0`; with High Power set beforehand (`powermode 2` under Battery Power), the read is refused, the log says so, and `powermode 2` is still set after the session | Not run |
| A kept brightness on a build that can make the call (after a refused restore, set the keyboard backlight by hand, put the display to sleep, then launch that build and wake the display): the log shows "not read: macOS has the backlight suppressed or dimmed" and then "set since its restore", and the backlight stays at the level set | Not run |
| A kept keyboard backlight when a session ends with the lid closed, on a build that can make the call: the log shows "the lid is not known to be open, so the kept value is not read", and about a minute or less after the lid opens, with no session, "set since its restore" or "keyboard backlight restored" | Not run |
| A kept brightness at a launch with no session and the lid closed (an external display in clamshell mode, after a refused restore), on a build that can make the call: the log shows "not read: the lid is not known to be open" for each kept device and nothing is written, and once the lid opens "set since its restore" or "restored" | Not run |
@@ -253,6 +254,8 @@ is performed. Do not replace "not run" with "passed" based on source review.
| `install.sh` and `uninstall.sh` from a release zip unpacked in `/tmp`, with a `build-app.sh` and a `backstop.sh` added to the unpacked folder, run neither (install without `--app` stops, uninstall runs the sealed copy), on a working Mac | Not run |
| `install.sh --app` from a release zip stops with the Apple Silicon message on an Intel Mac, and installs on an Apple Silicon Mac from a Terminal running under Rosetta | Not run |
| Release workflow end to end: tag push, tests, package, attestation, GitHub Release, `gh attestation verify` of the download | Not run |
+| Nightly channel end to end: scheduled run on `main`, tag `nightly--` created by the workflow, prerelease not marked Latest, `gh attestation verify --source-ref refs/heads/main --source-digest ` of the download, and a second run on the same commit skipped before building | Not run |
+| `v0.1.0` zip (hand-packaged from the installed app): `./scripts/install.sh --app ./Insomnia.app` on a working Mac, first launch, and `./scripts/uninstall.sh` from the unpacked folder | Not run |
| Install or uninstall whose own process is killed (`kill -9 `) during a `launchctl bootout` keeps the recovery lock until that bootout has ended or been stopped, about 33 s at most, and the app started afterwards keeps its agent loaded, on a working Mac | Not run |
| First launch over an existing install tightens Application Support/Insomnia and Logs/Insomnia to 0700 and their files to 0600 | Not run |
| Launch at login survives a reinstall by install.sh, including a second install.sh run on the same unchanged build (switch on, reinstall, relaunch: the log shows the launch-time check, System Settings > General > Login Items lists Insomnia as enabled, and the Settings switch reads on; a pending approval shows the note and the Open Login Items button) | Not run |
@@ -278,17 +281,56 @@ fixture. Real build, signing, privileged installation, and quit refusal by a
running app have not been exercised as an end-to-end installation on a
working Mac.
+## October 10, 2026 macOS 27.0.1 (MacBook Pro, Apple M5 Pro)
+
+Machine: MacBook Pro, Apple M5 Pro, 64 GB, macOS 27.0.1 (26A434). Build:
+main `b5f7cf0` plus the DisplayServices major gate opened to 27, compiled with
+the Command Line Tools' Swift 6.4 against the macOS 26.5 SDK (the macOS 27 SDK
+expands SwiftUI `@State` as a macro whose plugin ships with Xcode only, so a
+Command Line Tools build against it fails), ad-hoc signed, installed with
+`scripts/install.sh`.
+
+- A read-only probe on 27.0.1 found `DisplayServicesGetBrightness`,
+ `DisplayServicesSetBrightness` and `DisplayServicesCanChangeBrightness`;
+ `GetBrightness` returned 0 with brightness 1.0 and `CanChangeBrightness`
+ returned true for the built-in display. All six `KeyboardBrightnessClient`
+ type encodings matched the macOS 26 measurements.
+- Supervised lid test on AC power on a desk: a 900 s session, lid closed
+ for about 11 minutes. `pmset -g log` shows no sleep or wake in the window;
+ a logger sampling every 30 s kept running. The log shows "display
+ darkened (was brightness 1.0)"; the built-in brightness read 0.000 for the
+ whole closed period and was restored to 1.0 at the timer end. The keyboard
+ backlight, audio mute and the WhatsApp freeze were applied and undone.
+ Battery temperature (`ioreg` "Temperature") went from 34.0 to 35.4 °C
+ while charging from 48 to 67 %; thermal state stayed nominal.
+- Low Power Mode was never enabled in that session: macOS 27 `pmset -g
+ custom` prints `powermode` instead of `lowpowermode`, so the read failed
+ ("no lowpowermode line under Battery Power") on every power event. On the
+ same machine `sudo -n pmset -b lowpowermode 1` set `powermode 1` under
+ Battery Power and `lowpowermode 0` set it back to `0`, so the existing
+ sudoers commands still apply; only the read changes. The parser now falls
+ back to `powermode` and reads `powermode 2` (High Power) as unknown.
+ A standalone harness of the new parser read the live output as off and
+ passed 8 cases; the Swift test suite was not run on this machine (the
+ Command Line Tools have no XCTest), so hosted CI is the gate for it.
+- Not exercised: a lid-closed session with the new Low Power read, any
+ session on battery, a session longer than 15 minutes, or heavy load.
+
## Distribution boundary
Packaging is automated: `scripts/build-app.sh` makes the bundle, and the
-Release workflow tests, packages, checksums, attests and publishes it for a
-`v*` tag (`docs/releasing.md`). PackagingTests run a patched copy of
-`build-app.sh` with the real codesign, RecoveryScriptTests run `install.sh
---app` against prebuilt fixtures, one of them ad-hoc signed by the real
-codesign, and ReleaseWorkflowTests check that every action in the workflows
-is pinned to a commit and that no job has more than read access except the
-one that publishes. No release has been produced with it yet. Releases are
-ad-hoc signed and not notarized, published as prereleases. A consumer
-installation and recovery walkthrough from a downloaded zip has not
-been done. Open-source availability and a passing PR are not equivalent to
-readiness for a public binary release.
+Release workflow tests, packages, checksums, attests and publishes it, as a
+stable release for a `v*` tag and as a nightly prerelease of `main` once a
+day when it has new commits (`docs/releasing.md`). PackagingTests run a
+patched copy of `build-app.sh` with the real codesign, RecoveryScriptTests
+run `install.sh --app` against prebuilt fixtures, one of them ad-hoc signed
+by the real codesign, and ReleaseWorkflowTests check that every action in the
+workflows is pinned to a commit and that no job has more than read access
+except the ones that publish. No release has been produced with it yet.
+Releases are ad-hoc signed and not notarized; stable releases are marked
+Latest, and nightlies are prereleases never marked Latest. The first stable
+release, `v0.1.0`, was packaged by hand from the maintainer's installed app,
+not by the workflow, and has no attestation. A consumer installation and
+recovery walkthrough from a downloaded zip has not been done. Open-source
+availability and a passing PR are not equivalent to readiness for a public
+binary release.
diff --git a/docs/releasing.md b/docs/releasing.md
index 65b912d1..e0482c7f 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -4,13 +4,37 @@ How a downloadable Insomnia build is made, what signs it, and how to verify
one. The Release workflow (`.github/workflows/release.yml`) does the work;
this page is the checklist around it.
+## Channels
+
+| | Stable | Nightly |
+| --- | --- | --- |
+| Made by | pushing a tag `v` | the daily schedule (07:17 UTC), or a manual run with the nightly input, on `main` |
+| Tag | `v`, pushed by the maintainer | `nightly--`, created by the workflow |
+| Zip | `Insomnia--macos.zip` | `Insomnia--nightly---macos.zip` |
+| Title | `Insomnia (stable)` | `Insomnia nightly ()` |
+| GitHub marks | Latest, not a prerelease | Pre-release, never Latest |
+| Attestation source ref | `refs/tags/v` | `refs/heads/main` |
+
+Both channels run the same tests, build, package, checksum and attestation
+steps; they differ only in how the run starts, the names and the release
+flags. Nobody chooses a nightly: it is whatever `main` held when the run
+started.
+
+`v0.1.0` is an exception to this table. It was packaged by hand from the
+app installed on the maintainer's Mac and published as the first stable
+release; no workflow built it. Its zip is `Insomnia-0.1.0-macos-arm64.zip`
+with `scripts/install.sh`, `scripts/uninstall.sh` and `scripts/backstop.sh`
+from the `v0.1.0` tag, it has no attestation, and its release notes give its
+own checksum and install commands. Version 0.1.0 belongs to that release,
+so the next stable release needs a new version in `Resources/Info.plist`.
+
## What a release contains
-`Insomnia--macos.zip` holds one folder with `Insomnia.app`,
-`install.sh` and `uninstall.sh`. `SHA256SUMS` lists the zip's SHA-256. Both
-files are attached to the GitHub Release, and the zip carries a GitHub build
-provenance attestation that names the repository, the tag and the workflow
-run that produced it.
+`Insomnia--macos.zip` (or the nightly name above) holds one folder
+with `Insomnia.app`, `install.sh` and `uninstall.sh`. `SHA256SUMS` lists the
+zip's SHA-256. Both files are attached to the GitHub Release, and the zip
+carries a GitHub build provenance attestation that names the repository, the
+ref, the commit and the workflow run that produced it.
The binary is arm64 only, because the build job runs on an Apple Silicon
runner and no universal build is made. A release therefore runs on Apple
@@ -36,59 +60,102 @@ one, so its `install.sh` needs `--app`, and its `uninstall.sh` runs only the
`backstop.sh` sealed in the installed app, after verifying the app. A
`build-app.sh` or `backstop.sh` added to the unpacked folder is not run.
-## Cutting a release
+## Cutting a stable release
1. Set `CFBundleShortVersionString` in `Resources/Info.plist` to the new
- version (and bump `CFBundleVersion`). Commit on main.
+ version (and bump `CFBundleVersion`). Commit on main. `0.1.0` is taken
+ (see Channels).
2. Tag that commit `v`, the same digits, and push the tag:
```bash
- git tag v0.1.0
- git push origin v0.1.0
+ git tag v0.2.0
+ git push origin v0.2.0
```
The workflow refuses a tag whose digits differ from the plist.
3. Watch the run. It runs `swift test`, builds and ad-hoc signs the bundle,
- packages it, attests the zip and creates the GitHub Release with notes
- that include the checksum and the verify commands.
+ packages it, attests the zip and creates the GitHub Release, marked
+ Latest, with notes that include the checksum and the verify commands.
+ `gh release create` fails if the tag already has a release, so a rerun
+ never replaces published assets.
4. Download the zip from the release and check it the way a user would (see
below), then install it with the command in the release notes
(`./install.sh --allow-unverified-origin --app ./Insomnia.app`) on a Mac
you can afford to reinstall on. Record the result in
`docs/release-validation.md`.
-A manual run (Actions, Release, Run workflow) builds and packages the current
-branch and uploads the zip and `SHA256SUMS` as a workflow artifact. It does
-not attest or publish anything, so it is the way to try the pipeline.
+A manual run (Actions, Release, Run workflow) with the nightly box left
+unticked builds and packages the chosen branch and uploads the zip and
+`SHA256SUMS` as a workflow artifact. It does not attest or publish anything,
+so it is the way to try the pipeline.
+
+## Nightly prereleases
+
+The schedule runs the workflow on `main` once a day. A first job, with
+read-only access, lists the repository's `nightly-*` tags and releases:
+
+- If a nightly tag already names the head of `main` and has a release,
+ nothing is built. A day without new commits publishes nothing.
+- If such a tag exists without a release, the run fails and builds nothing,
+ until `main` moves on. That happens only when an earlier run created the
+ tag and then failed to create the release: rerun that run's failed jobs
+ (the nightly job finds its own tag on the same commit and goes on), or
+ delete the tag.
+- If a nightly tag with this commit's first 12 hex digits points at another
+ commit, the run fails.
+- Otherwise it picks the tag `nightly--` and the run
+ goes on to test, build and package that commit.
+
+The publishing job creates the tag through the API call that fails when the
+ref exists, so it never moves a tag, then creates the release with
+`--prerelease --latest=false`. Scheduled and manual runs on `main` share one
+concurrency group, so two runs never publish the same commit at once.
+
+To publish a nightly now, start a manual run on `main` with the nightly box
+ticked. The same checks apply, and a run on any other branch fails before
+it builds.
+
+GitHub disables scheduled workflows in a public repository after 60 days
+without repository activity; the Release workflow then has to be enabled
+again under Actions. The tags are not protected by a repository rule, so
+someone with write access could still move or delete one by hand.
## Signing
-Releases are ad-hoc signed and not notarized. `build-app.sh` signs with
-`codesign --sign -`, so the workflow uses no secrets and no keychain, and the
-signature identifies only that build (its cdhash). The release is marked a
-prerelease, and macOS blocks the first launch of the downloaded app until the
-user allows it in System Settings > Privacy & Security.
+Releases on both channels are ad-hoc signed and not notarized.
+`build-app.sh` signs with `codesign --sign -`, so the workflow uses no
+secrets and no keychain, and the signature identifies only that build (its
+cdhash). The stable label is about the channel, not the signature: macOS
+blocks the first launch of any downloaded release until the user allows it
+in System Settings > Privacy & Security.
## Verifying a download
```bash
shasum -a 256 -c SHA256SUMS
-gh attestation verify Insomnia--macos.zip -R krishhgg/Insomnia \
+gh attestation verify -R krishhgg/Insomnia \
--signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
- --source-ref refs/tags/v
+ --source-ref [ --source-digest ]
```
+`[` is `refs/tags/v]` for a stable release and `refs/heads/main`
+for a nightly; `` is the full commit the release was built from. The
+release notes carry the command filled in.
+
The first line checks the zip against the checksum published with it. The
second asks GitHub for the attestation signed when the workflow ran and
checks that this zip is its subject, that the signing workflow is this
repository's `release.yml` (`--signer-workflow`; with `-R` alone any workflow
-of the repository would do) and that it ran for the tag (`--source-ref`).
-Together they show the bytes are what the Release workflow built from the
-tagged commit. They do not show the code is safe; the README's warnings
-apply to every build.
-
-`install.sh --app` then copies the bundle into a private temporary
-directory and checks that copy before the password prompt: it runs
+of the repository would do), that it ran for that ref (`--source-ref`), so a
+nightly built from `main` does not pass for a stable tag, and that it built
+that commit (`--source-digest`). Together they show the bytes are what the
+Release workflow built from that commit. They do not show the code is safe;
+the README's warnings apply to every build. `v0.1.0` has no attestation; only
+the checksum in its release notes covers it.
+
+The `install.sh --app` in a workflow-built zip then copies the bundle into
+a private temporary directory and checks that copy before the password
+prompt: it runs
`codesign --verify --strict --deep` and checks the bundle identifier and
version. It installs that copy, not the path it was given, so a bundle
replaced at that path while the prompt waits is never installed. The copy it
@@ -97,16 +164,18 @@ recovery agent pins.
What is verified: the checksum shows the zip was not altered after
`SHA256SUMS` was written; the attestation shows this repository's Release
-workflow built this exact zip from the tag; `install.sh --app` shows the
-bundle inside is intact (signature and resource seal), has the expected
-identifier and a version, and carries the sealed backstop. What is not:
-`install.sh` cannot tell a bundle from this repository apart from one anyone
-else signed with the same identifier, and it does not take any signature,
-ad-hoc or not, as proof of where a bundle came from. So it refuses to install
-without `--allow-unverified-origin`, the flag that says you ran the two
-commands above yourself. The release notes carry that flag in the install command.
+workflow built this exact zip from that ref and commit; `install.sh --app`
+shows the bundle inside is intact (signature and resource seal), has the
+expected identifier and a version, and carries the sealed backstop. What is
+not: `install.sh` cannot tell a bundle from this repository apart from one
+anyone else signed with the same identifier, and it does not take any
+signature, ad-hoc or not, as proof of where a bundle came from. So it
+refuses to install without `--allow-unverified-origin`, the flag that says
+you ran the two commands above yourself. The release notes carry that flag
+in the install command.
## What is not automated
The workflow does not install anything anywhere. The release validation
-record is still written by hand after a real install.
+record is still written by hand after a real install. Nothing checks a
+nightly beyond the workflow's `swift test`.
diff --git a/docs/spec.md b/docs/spec.md
index 4ce3f3d3..c8598eb6 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -152,7 +152,7 @@ The display and keyboard rows go through private frameworks with no ABI
contract: the DisplayServices C functions and CoreBrightness's
`KeyboardBrightnessClient`. Two guards run once per launch, before the
first call. The DisplayServices functions are called only on a macOS major
-version they were measured on (26, `DisplayPower.measuredDisplayServicesMajors`);
+version they were measured on (26 and 27, `DisplayPower.measuredDisplayServicesMajors`);
a C symbol carries no type information, so on any other major the display
is left alone until someone measures again. The `KeyboardBrightnessClient`
methods are read through the Objective-C runtime and compared with the type
@@ -766,7 +766,7 @@ provided by the standalone backstop. Performance effects depend on workload.
skipped and logged.
- Post a notification: "Network was down 2m 10s. Nudged 2 tmux panes.
Check GUI agents."
-- Recommended one-time setting, documented in the README: System Settings >
+- Recommended one-time setting, documented in docs/extras.md: System Settings >
Wi-Fi > "Ask to join hotspots" = Automatically.
### 8. Reconcile and backstop (recovery goals)
@@ -1291,7 +1291,12 @@ Insomnia/
simulate-lid.sh file trigger for the lid-close action path (debug and
INSOMNIA_LID_SIMULATION=1 builds only)
docs/spec.md
- README.md setup, hotspot setting, Chrome note
+ docs/install.md install, upgrade and uninstall steps
+ docs/lid-close.md lid-close actions and their defaults
+ docs/recovery.md recovery details and manual attention
+ docs/extras.md hotspot, tmux, Chrome, configuration and privacy
+ skills/ agent playbooks: install-insomnia, maintain-insomnia
+ README.md short overview that links the docs
```
## Install
diff --git a/skills/install-insomnia/SKILL.md b/skills/install-insomnia/SKILL.md
new file mode 100644
index 00000000..c5b6c068
--- /dev/null
+++ b/skills/install-insomnia/SKILL.md
@@ -0,0 +1,44 @@
+---
+name: install-insomnia
+description: Install or upgrade Insomnia, the macOS menu bar app that keeps a Mac awake for a timed session, from a GitHub release or from source. Use when the user asks to install, update or reinstall Insomnia.
+---
+
+# Install Insomnia
+
+The exact commands are in
+[docs/install.md](https://github.com/krishhgg/Insomnia/blob/main/docs/install.md).
+Read that page, then follow these steps. Never run `sudo` yourself and never
+pass a password to it. When a step needs the administrator password, give the
+user the exact command to run in Terminal and wait for them to say it finished.
+
+1. **Check the Mac.** Insomnia needs macOS 26 or later (`sw_vers -productVersion`).
+ Release zips run only on Apple Silicon: `sysctl -n hw.optional.arm64` prints
+ `1`. On an Intel Mac, use "Build from source" and tell the user that build is
+ untested on Intel.
+2. **Pick the channel.** Use the latest stable release unless the user asked
+ for the newest nightly. If the release is `v0.1.0`, follow its release notes
+ instead of docs/install.md.
+3. **Download and verify.** Follow "Install a release yourself", steps 1 and
+ 2. Run both checks. Keep `--source-digest` and the full 40-character commit.
+ If either check fails, stop and tell the user. Do not download a different
+ file to get past it.
+4. **Install.** Step 3 of the same section runs `install.sh`, which asks for
+ the administrator password, so hand that command to the user. Before they
+ run it, tell them the installer adds a sudoers rule. That rule lets their
+ account run four `pmset` power commands without a password, and the page's
+ "What gets installed" section lists them. `--allow-unverified-origin`
+ tells the installer that both download checks passed. Never suggest it
+ when they did not.
+5. **First launch.** macOS blocks the first launch of a downloaded app until
+ the user allows it in System Settings > Privacy & Security.
+6. **Confirm.** The eye shows in the menu bar, and
+ `launchctl print gui/$(id -u)/com.insomnia.backstop` finds the recovery
+ agent. Ask the user to review the lid-close options in Settings before the
+ first session.
+
+An upgrade is the same steps. The installer asks the running app to quit and
+stops if a session is still waiting to be undone. Then use the
+maintain-insomnia skill to find out why.
+
+To build from source, follow "Build from source" on the same page. Its
+`./scripts/install.sh` also needs the password.
diff --git a/skills/maintain-insomnia/SKILL.md b/skills/maintain-insomnia/SKILL.md
new file mode 100644
index 00000000..f95474f2
--- /dev/null
+++ b/skills/maintain-insomnia/SKILL.md
@@ -0,0 +1,69 @@
+---
+name: maintain-insomnia
+description: Check on, troubleshoot, or uninstall an installed Insomnia (the macOS menu bar app that keeps a Mac awake for a timed session). Use when the user asks why Insomnia shows a warning, whether a session cleaned up after itself, why the hotspot password stopped working, or how to remove it.
+---
+
+# Maintain Insomnia
+
+Reference pages in https://github.com/krishhgg/Insomnia:
+[docs/recovery.md](https://github.com/krishhgg/Insomnia/blob/main/docs/recovery.md),
+[docs/install.md](https://github.com/krishhgg/Insomnia/blob/main/docs/install.md),
+[docs/lid-close.md](https://github.com/krishhgg/Insomnia/blob/main/docs/lid-close.md) and
+[docs/extras.md](https://github.com/krishhgg/Insomnia/blob/main/docs/extras.md).
+
+## Rules
+
+- Never run `sudo`, `pmset` changes, `kill` or `kill -CONT` yourself. Show the
+ user the command, say why, and let them run it.
+- Never edit or delete files in `~/Library/Application Support/Insomnia/`.
+ `state.json` there is the recovery journal: the list of changes Insomnia
+ still has to undo.
+- Never signal a pid taken from an old log line. Pids get reused. Check it with
+ `ps -o pid,stat,lstart,command -p ` first.
+- Kong's Insomnia API client uses the same support folder name.
+ `brew uninstall --zap insomnia` for that app moves this app's journal to the
+ Trash. Check which app the user means before anything like that.
+
+## Check the state
+
+1. `tail -n 100 ~/Library/Logs/Insomnia/insomnia.log` shows what the app and
+ the recovery agent did last.
+2. `pmset -g | grep -i sleepdisabled` shows `1` while sleep is turned off.
+3. `cat ~/Library/Application\ Support/Insomnia/state.json` shows what is
+ still waiting to be undone. These entries are pending:
+ - `sleepDisabledByUs`, `lowPowerSetByUs` or `dockerFrozen` set to `true`
+ - a non-empty `frozenProcesses`, `appNapOverrides` or `savedAudioOutputs`
+ - `savedOutputVolume` or `savedMuted`
+ - `savedDisplayBrightness` or `savedKeyboardBrightness`, unless
+ `displayRestoreRefused` or `keyboardRestoreRefused` is `true`
+
+ Other keys, such as `keptDisplayReadLit`, `keptDisplayUnderLowPower` and
+ `displayRestoredUnderLowPower`, are records the app keeps, not changes to
+ undo. A brightness value kept after a refused restore blocks nothing.
+ Saved audio for an output that isn't connected waits for it to reconnect
+ and doesn't block ending a session.
+4. `launchctl print gui/$(id -u)/com.insomnia.backstop` shows whether the
+ recovery agent is loaded.
+
+## Warnings in the menu
+
+Match the warning to docs/recovery.md, "Recovery limits and manual attention".
+
+- **A stuck power command with a pid.** The user checks the pid, then runs the
+ `sudo kill ` the menu shows.
+- **Stopped processes Insomnia cannot prove it froze.** The user checks each
+ pid with `ps` and decides whether to run `kill -CONT `.
+- **An output device still muted.** Reconnect it and open Insomnia, or choose
+ "Stop waiting for " in the menu.
+- **Sleep disabled by something else.** Insomnia did not set it and leaves it
+ alone. Whoever set it turns sleep back on with
+ `sudo pmset -a disablesleep 0`.
+- **Hotspot password unreadable by this build.** A reinstall changes the app's
+ signature. Enter the password again in Settings.
+
+## Uninstall
+
+Follow "Uninstall" in docs/install.md and hand the user the command, since it
+asks for the password. It stops when something is still waiting to be
+undone. Fix that first, then run it again. A failed uninstall does not mean
+the power settings are back to normal, so check `pmset -g` afterward.