diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cfda072..3daca25 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,25 +3,25 @@ ## Prerequisites You need macOS, Xcode 26.6 or later, and Swift 6.3 or later. You also need -SwiftLint 0.65.0 and [actionlint](https://github.com/rhysd/actionlint). +XcodeGen 2.46.0, SwiftLint 0.65.0, actionlint 1.7.12, Node.js, and npm. Install the development tools with: ```bash -brew install actionlint swiftlint +brew install actionlint node swiftlint xcodegen ``` -The future companion app also requires -[XcodeGen](https://github.com/yonaskolb/XcodeGen). - ## Setup -Fork the repository, clone your fork, and create a branch from `main`. Resolve -the package dependencies once after cloning: +Fork the repository, clone your fork, and create a branch from `main`. Generate +the Xcode project and resolve the package dependencies after cloning: ```bash +xcodegen generate swift package resolve ``` +Do not commit `Clamshell.xcodeproj`. XcodeGen creates it from `project.yml`. + Do not run tests against live privileged paths. Tests must use fake process and filesystem boundaries; they must never change `pmset`, invoke `sudo`, or write to `/Library/PrivilegedHelperTools` or `/etc/sudoers.d`. @@ -32,6 +32,7 @@ Run the complete local gate before each pull request: ```bash scripts/check.sh +npx --yes markdownlint-cli2 '**/*.md' '#.build' ``` The script checks formatting, SwiftLint, tests, debug and release builds, and @@ -63,6 +64,10 @@ include product names, commands, code identifiers, API names, quoted interface text, and standard names. Do not replace a precise technical term with an ambiguous word. +Keep guides in `docs/`. Keep standard community files in `.github/`. Do not +commit AI plans or internal process notes. The repository ignores +`docs/plans/`. + ## Commits Every commit and pull-request title must use: @@ -93,9 +98,9 @@ change to command output, exit status, system paths, permissions, sudoers policy, or the helper allow-list. Include the commands you ran and update user documentation when behaviour changes. -Maintainer approval and all required checks are needed before merge. The -repository uses squash merging, so the pull-request title becomes the release -commit subject. +A maintainer must approve the pull request before merge, and all required +checks must pass. The repository uses squash merging, so the pull-request title +becomes the release commit subject. ## Security diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 80b67fc..d138500 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,8 +2,8 @@ ## Supported versions -Only the latest published release receives security fixes. The project has not -published its first release yet. +The maintainer provides security fixes only for the latest published release. +Update to the latest release before you report a problem that is fixed there. ## Report a vulnerability @@ -18,11 +18,15 @@ and keep you informed while we validate and fix it. ## Security-sensitive surfaces Changes to the root-owned helper, sudoers policy, installation paths, ownership -or permissions, process execution, and unsigned app distribution require extra -review. The helper must continue to accept only the exact `enable` and -`disable` actions. The sudoers policy must not grant password-free access to -the public CLI, `pmset`, a shell, or a user-writable executable. - -The planned companion app and DMG will use ad-hoc signing without Apple -notarisation. Release documentation must state that boundary and provide the -specific Gatekeeper approval steps without implying Apple review. +or permissions, process execution, Control Centre request boundary, release +workflow, checksum, and unsigned app distribution require extra review. The +helper must continue to accept only the exact `enable` and `disable` actions. +The sudoers policy must not grant password-free access to the public CLI, +`pmset`, a shell, or a user-writable executable. + +The companion app and DMG use ad-hoc signing without Apple notarisation. +Release documentation must state that boundary and provide the specific +Gatekeeper approval steps without implying Apple review. + +Read the [privilege model](../docs/privilege-model.md) for the intended paths, +permissions, commands, and Battery Power boundary. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 843bae3..e7436e9 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -1,5 +1,12 @@ # Support +Start with the public guides: + +- [Installation](../docs/installation.md) +- [Usage](../docs/usage.md) +- [Troubleshooting](../docs/troubleshooting.md) +- [Privilege model](../docs/privilege-model.md) + - Report reproducible defects with the [bug form](https://github.com/LMLiam/clamshellctl/issues/new?template=bug.yml). - Propose a change with the [feature form](https://github.com/LMLiam/clamshellctl/issues/new?template=feature.yml). - Report vulnerabilities through [private vulnerability reporting](https://github.com/LMLiam/clamshellctl/security/advisories/new). @@ -8,6 +15,10 @@ This is a community project. Opening an issue does not guarantee individual support or a response time. +You can use the issue tracker for `clamshellctl` on its documented macOS +versions. The maintainer does not provide general macOS, hardware, display, +dock, or battery support. + Safe diagnostics include `clamshellctl --version` and `clamshellctl status`. Review output before posting it. Do not publish sudoers contents when they contain unexpected local changes; use private vulnerability reporting if those diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..f61f3ab --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,8 @@ +config: + default: true + MD013: false + MD033: false + MD041: false +ignores: + - .build/** + - docs/plans/** diff --git a/CHANGELOG.md b/CHANGELOG.md index c07015e..0fe7d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,17 @@ # Changelog -## 0.1.0 (2026-07-29) +Release Please records notable changes to `clamshellctl` here. +## 0.1.0 (2026-07-29) ### Features * **cli:** add secure clamshell control foundation ([#11](https://github.com/LMLiam/clamshellctl/issues/11)) ([b019e1b](https://github.com/LMLiam/clamshellctl/commit/b019e1bef18a9294090da25037b0ba774ede9cba)) - ### Fixes * **release:** start at version 0.1.0 ([#14](https://github.com/LMLiam/clamshellctl/issues/14)) ([ae17621](https://github.com/LMLiam/clamshellctl/commit/ae17621f2ab06ae004f4dee0876790cb6fa12ed1)) - ### Documentation * **design:** add Control Centre companion ([7e59069](https://github.com/LMLiam/clamshellctl/commit/7e590690663cf60a4139683f389daa2d83f13f53)) @@ -21,7 +20,3 @@ * **plan:** add companion app delivery ([85b9eaf](https://github.com/LMLiam/clamshellctl/commit/85b9eaf3ef301455a36f8fd763eae8631f040312)) * **plan:** define clamshellctl implementation ([23ee89d](https://github.com/LMLiam/clamshellctl/commit/23ee89d707c4191bb61b90293a5a23a0af44fbd6)) * **project:** add public repository foundation ([075ee59](https://github.com/LMLiam/clamshellctl/commit/075ee595110c683ece57c005cb4aa2e469fbc403)) - -## Changelog - -Notable changes to `clamshellctl` are recorded here by release-please. diff --git a/README.md b/README.md index 4935d52..bea7a25 100644 --- a/README.md +++ b/README.md @@ -9,80 +9,77 @@ Control battery clamshell mode on macOS. +`clamshellctl` lets a Mac notebook use an external display while the notebook +is closed and on battery power. You can use a terminal command on macOS 13 or +later. On macOS 26 or later, you can also use a Control Centre control. + > [!IMPORTANT] -> `clamshellctl` is under active development and is not ready to install. The -> repository does not yet publish a Homebrew formula or companion-app DMG. +> The first public release is not available. Do not use an asset from an +> untrusted source. You can build the project from source while release work +> continues. -## What it will provide +## Features -- A terminal-first `clamshellctl` command installed through Homebrew. -- Verified `status`, `enable`, `disable`, and `toggle` operations. -- Optional temporary enablement with automatic disablement. -- A self-contained macOS companion app with a stateful Control Centre toggle. -- One explicit administrator-authorised setup step, followed by narrowly - restricted password-free operations. +- Read, enable, disable, or toggle battery clamshell mode. +- Enable the mode for up to 30 days. +- Use a stateful Control Centre control on macOS 26 or later. +- Install one restricted helper for password-free routine changes. +- Keep the AC Power setting unchanged. -The CLI will support macOS 13 and later. The optional native companion will -require macOS 26 or later because that is where WidgetKit controls became -available on Mac. +## Install -## Temporary enablement +The project will provide two installation methods: -Use `enable --for` with a whole number of minutes, hours, or days: +- **Homebrew:** Installs the terminal command and helper payload on macOS 13 or + later. +- **DMG:** Installs a self-contained app, Control Centre extension, terminal + command, and helper payload on macOS 26 or later. -```bash -clamshellctl enable --for 30m -clamshellctl enable --for 2h -clamshellctl enable --for 1d -``` +Read the [installation guide](docs/installation.md) for the requirements, +checksum check, Gatekeeper approval, and setup steps. -The maximum duration is 30 days. The command stores an absolute deadline and -uses a user LaunchAgent to disable battery clamshell mode. Sleep does not reset -the duration. A new timed command replaces the current timer. `enable` without -`--for` and `disable` cancel the current timer. +## Use the command -## Safety model +Run setup once after installation: -The project changes only the Battery Power `disablesleep` setting. It does not -change the corresponding AC Power setting. - -Normal mutations will pass through a root-owned helper that accepts only the -exact `enable` and `disable` operations. The sudoers policy will grant no -password-free access to the public CLI, app bundle, arbitrary `pmset` -arguments, or a shell. After each mutation, `clamshellctl` will reread `pmset` -before reporting success. +```bash +sudo clamshellctl setup +``` -## Project status +Then use the command without a password: -The CLI, privileged helper, companion app, and Control Centre control are in -active development. Automated tests cover each published behaviour. +```bash +clamshellctl status +clamshellctl enable +clamshellctl enable --for 2h +clamshellctl disable +clamshellctl toggle +``` -## Development +Read the [usage guide](docs/usage.md) for timers, Control Centre, and removal. -Requirements: +## Safety -- macOS -- Xcode 26.6 or later -- Swift 6.3 or later +The restricted helper accepts only `enable` and `disable`. It changes only the +Battery Power `disablesleep` setting. The sudoers policy does not give +password-free access to the public command, the app, a shell, or other +`pmset` arguments. -Run the current checks with: +Read the [privilege model](docs/privilege-model.md) for the installed paths, +file permissions, and command boundary. -```bash -scripts/check.sh -``` +## Help -See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for setup, style, testing, and -commit conventions. +- Use the [troubleshooting guide](docs/troubleshooting.md) for common problems. +- Read [SUPPORT.md](.github/SUPPORT.md) before you ask a usage question. +- Report a security problem through the process in + [SECURITY.md](.github/SECURITY.md). -## Community +## Contribute -- Use the [issue forms](https://github.com/LMLiam/clamshellctl/issues/new/choose) - for bugs and feature proposals. -- Read [SUPPORT.md](.github/SUPPORT.md) before asking a usage question. -- Report vulnerabilities through the private process in - [SECURITY.md](.github/SECURITY.md). -- Follow the [Code of Conduct](.github/CODE_OF_CONDUCT.md) when participating. +Read [CONTRIBUTING.md](.github/CONTRIBUTING.md) for the development requirements, +checks, style rules, and pull request process. ## Licence -`clamshellctl` is available under the [MIT License](LICENSE). +`clamshellctl` is available under the [MIT Licence](LICENSE). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ec46e42 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,111 @@ +# Installation + +The first public release is not available. The commands in this guide will +work after the project publishes the release assets and Homebrew formula. + +## Choose an installation method + +| Method | macOS | Includes Control Centre | Includes terminal command | +| --- | --- | --- | --- | +| Homebrew | 13 or later | No | Yes | +| DMG | 26 or later | Yes | Optional link | + +Both methods include the restricted helper payload. Installation does not add +privileged files. You approve privileged setup in a separate step. + +## Install with Homebrew + +You need macOS 13 or later and a current Homebrew installation. + +1. Install the formula from the project tap: + + ```bash + brew install LMLiam/tap/clamshellctl + ``` + +2. Install the restricted helper and sudoers policy: + + ```bash + sudo clamshellctl setup + ``` + +3. Check the installation: + + ```bash + clamshellctl --version + clamshellctl status + ``` + +The formula does not install privileged files during `brew install`. The +`setup` command is the only installation step that needs administrator access. + +Homebrew documents how a direct install from a +[third-party tap](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap#installing) +limits trust to the selected formula. + +## Install the companion app from a DMG + +You need macOS 26 or later. The app and all command payloads contain `arm64` +and `x86_64` code. + +### Verify the download + +Download the DMG and its `.sha256` file from the same GitHub release. Replace +`X.Y.Z` in this command with the release version: + +```bash +cd ~/Downloads +shasum -a 256 -c clamshellctl-vX.Y.Z.dmg.sha256 +``` + +Continue only if the command reports `OK`. + +### Copy and approve the app + +1. Open the DMG. +2. Drag `Clamshell.app` to the Applications link. +3. Eject the DMG. +4. Open Clamshell from the Applications folder or Spotlight. + +The app has an ad-hoc signature and is not notarised. macOS blocks its first +launch. This warning is expected, but it means that Apple did not verify the +developer or scan this build during notarisation. + +If you trust the GitHub release and its checksum: + +1. Try to open Clamshell once. +2. Open **System Settings** and select **Privacy & Security**. +3. Go to **Security** and select **Open Anyway**. +4. Confirm that you want to open Clamshell. + +Apple explains this process and its risks in +[Open a Mac app from an unknown developer](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unknown-developer-mh40616/mac). + +### Complete setup + +1. Select **Set Up** in the Clamshell window. +2. Approve the administrator prompt. +3. Open Control Centre and select **Edit Controls**. +4. Add **Battery Clamshell Mode**. + +Enable **Install the Terminal command** before setup if you also want +`/usr/local/bin/clamshellctl`. You can select **Install Terminal Command** +after setup if you did not enable it at first. + +## Update + +For Homebrew, run: + +```bash +brew update +brew upgrade clamshellctl +``` + +For the DMG, download and verify the new release. Replace the app in +`/Applications`, then open it. Select **Set Up** if the app reports that the +helper needs repair. This step replaces an old helper with the app payload. + +## Remove + +Follow the [removal steps](usage.md#remove-clamshellctl). Disable battery +clamshell mode before you remove the helper or app. diff --git a/docs/privilege-model.md b/docs/privilege-model.md new file mode 100644 index 0000000..8a50eb6 --- /dev/null +++ b/docs/privilege-model.md @@ -0,0 +1,78 @@ +# Privilege model + +Routine state changes need root access because macOS restricts `pmset` changes. +`clamshellctl` uses a small helper and an exact sudoers allow-list for these +changes. + +## Setup boundary + +The Homebrew command uses this explicit setup step: + +```bash +sudo clamshellctl setup +``` + +The companion app shows one macOS administrator prompt for the same operation. +Setup does these actions: + +1. Copy the helper payload to its managed path. +2. Set the helper owner to `root:wheel` and its mode to `0755`. +3. Create a sudoers policy for the user who started `sudo`. +4. Set the policy owner to `root:wheel` and its mode to `0440`. +5. Validate the staged policy with `visudo`. +6. Replace the managed policy and helper. +7. Verify the files and both permitted commands. + +Repeated setup does not replace valid files. + +## Managed privileged paths + +| Path | Purpose | Owner and mode | +| --- | --- | --- | +| `/Library/PrivilegedHelperTools/clamshellctl-helper` | Runs one state change | `root:wheel`, `0755` | +| `/etc/sudoers.d/clamshellctl` | Allows two helper commands | `root:wheel`, `0440` | + +The optional app command link is `/usr/local/bin/clamshellctl`. Setup does not +replace an existing file or a link to another command. + +## Runtime boundary + +The sudoers policy allows only these command forms for the setup user: + +```text +/Library/PrivilegedHelperTools/clamshellctl-helper enable +/Library/PrivilegedHelperTools/clamshellctl-helper disable +``` + +The helper rejects all other arguments. It maps the accepted actions to these +system changes: + +```text +/usr/bin/pmset -b disablesleep 1 +/usr/bin/pmset -b disablesleep 0 +``` + +The `-b` option limits the change to Battery Power. The helper does not change +the AC Power setting. + +The policy does not allow password-free access to: + +- `clamshellctl` +- `Clamshell.app` +- a shell +- general `pmset` commands +- another executable or argument + +The public command uses `sudo -n` for a permitted helper action. It does not +show an administrator prompt during a routine state change. If setup is not +valid, the command stops and tells you to run setup. + +## DMG trust boundary + +The release DMG and nested app files use ad-hoc signatures. The packaging +process verifies these signatures, but Apple does not identify the developer +or notarise the app. Verify the SHA-256 file before you approve the app in +Privacy & Security. + +The planned Homebrew formula will build the command and helper from the tagged +source. It will not install privileged files during `brew install`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..7406927 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,90 @@ +# Troubleshooting + +Start with these commands when the terminal command is available: + +```bash +clamshellctl --version +clamshellctl status +``` + +For a DMG installation without the optional terminal link, use: + +```bash +/Applications/Clamshell.app/Contents/MacOS/clamshellctl status +``` + +## macOS blocks the app + +The DMG is not notarised. Verify the downloaded checksum first. Then try to +open the app and use **Privacy & Security > Open Anyway**. Read the detailed +[Gatekeeper steps](installation.md#copy-and-approve-the-app). + +## Battery Clamshell Mode is not in Control Centre + +Check each requirement: + +- The Mac uses macOS 26 or later. +- `Clamshell.app` is in `/Applications`. +- You opened the installed app at least once. +- The app contains `Contents/PlugIns/ClamshellControl.appex`. + +Open Clamshell from Spotlight. Complete setup, then open Control Centre and +select **Edit Controls** again. + +## The control returns to the clear state + +The control shows the system state, not the last requested state. Run +`clamshellctl status`. If setup is missing or invalid, open Clamshell and select +**Set Up**. The app can replace an invalid helper. + +If the state still does not change, include the safe error text in a bug report. +Do not include passwords, account names, or a complete environment dump. + +## The app reports incomplete files + +The app needs its bundled command, helper, and Control Centre extension. + +If the window shows **Remove Privileged Setup**, select it first. Then remove +the incomplete app and install a complete copy in `/Applications`. + +If the removal button is not available, install a complete app first. Open it +and remove or repair the privileged setup from the new copy. + +## Setup reports a command link conflict + +The app creates `/usr/local/bin/clamshellctl` only when that path is free or +already links to the installed app command. It does not replace another file. + +Inspect the path before you change it: + +```bash +ls -l /usr/local/bin/clamshellctl +``` + +Keep the existing command, or move it to a safe backup location. Then run setup +again. + +## Status reports that a timer deadline passed + +Run: + +```bash +clamshellctl disable +``` + +This command disables battery clamshell mode and removes the stale timer files. + +## An update reports that setup needs repair + +The installed helper does not match the new payload. For Homebrew, run: + +```bash +sudo clamshellctl setup +``` + +For the companion app, open Clamshell and select **Set Up**. + +## Get more help + +Read [SUPPORT.md](../.github/SUPPORT.md) and use the correct issue form. Use +private vulnerability reporting for a security problem. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..5c3137e --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,88 @@ +# Usage + +Run `clamshellctl` without a subcommand to show the current state. + +## Read the state + +```bash +clamshellctl status +``` + +The command reports `enabled` or `disabled`. It also reports the end time for +temporary enablement. + +## Change the state + +```bash +clamshellctl enable +clamshellctl disable +clamshellctl toggle +``` + +`enable` allows clamshell mode while the Mac uses battery power. `disable` +prevents it. `toggle` changes the current state. + +The command reads the system state after a change. It reports success only if +the requested state is active. + +Use `--quiet` with a state change, setup, or removal command to suppress +successful output. Errors still use standard error. The `status` command does +not accept `--quiet` because its purpose is to print the state. + +## Use temporary enablement + +Add `--for` and a whole number of minutes, hours, or days: + +```bash +clamshellctl enable --for 30m +clamshellctl enable --for 2h +clamshellctl enable --for 1d +``` + +The maximum duration is 30 days. The command records an absolute deadline and +uses a user LaunchAgent to disable the mode. Sleep does not restart the +duration. + +A new temporary enablement replaces the current timer. `enable` without +`--for` and `disable` remove the current timer. + +## Use Control Centre + +The Control Centre control requires macOS 26 or later and the DMG installation. + +1. Open Control Centre and select **Edit Controls**. +2. Add **Battery Clamshell Mode**. +3. Select the control to enable or disable the mode. + +The filled state means that battery clamshell mode is enabled. The clear state +means that it is disabled. The control reads the system state again after each +request. + +The companion app runs in the background for control requests. It has no Dock +icon. If you open Clamshell from Spotlight, the existing app instance shows the +setup window. + +## Remove clamshellctl + +Disable the mode before removal. This action also removes an active timer. + +### Homebrew installation + +```bash +clamshellctl disable +sudo clamshellctl uninstall +brew uninstall clamshellctl +``` + +### DMG installation + +Run these commands while the app is still in `/Applications`: + +```bash +/Applications/Clamshell.app/Contents/MacOS/clamshellctl disable +sudo /Applications/Clamshell.app/Contents/MacOS/clamshellctl uninstall --remove-command +``` + +Then move `Clamshell.app` to the Bin. The removal command deletes only the +managed helper, sudoers policy, and app-owned terminal link. It does not delete +an unrelated item at `/usr/local/bin/clamshellctl`.