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 @@ Experimental source build

-> **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. Illustrated Settings defaults: Slack, WhatsApp, and Discord on the freeze list, Docker's idle rule off, mute on. During a session, lid close applies configured actions; reopening attempts to resume verified owned freezes and restore saved audio. The session continues. Without an active session, lid changes do nothing.

-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. The app and a launchd backstop coordinate through a shared lock and recovery journal. The app handles normal cleanup. The backstop checks every minute and attempts due recovery, leaving valid active sessions alone. Failed or unreadable recovery evidence stays on disk; saved audio needs the app and unconfirmed stopped processes need inspection.

-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-