From 5474c689e2191cf07beff8728e3cfdd3db3c8469 Mon Sep 17 00:00:00 2001 From: hyorigo <67533928+hyorigo@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:51:48 +0800 Subject: [PATCH] feat: add independent battery diagnostics --- 1bite | 25 + AGENTS.md | 1 + Makefile | 2 +- README.md | 6 + VERSION | 2 +- config/repository-files.txt | 6 + docs/automation.md | 4 + docs/battery.md | 72 + docs/battery.schema.json | 234 +++ docs/development.md | 4 + docs/installation.md | 1 + scripts/battery.sh | 1710 +++++++++++++++++ scripts/verify.sh | 1 - .../battery/pmset-log-batt-uppercase.txt | 4 + tests/fixtures/battery/pmset-log-batt.txt | 9 + tests/fixtures/config-golden.json | 2 +- tests/test_battery.py | 1030 ++++++++++ tests/test_kiro_shell.py | 12 +- tests/test_setup.py | 42 +- 19 files changed, 3160 insertions(+), 7 deletions(-) create mode 100644 docs/battery.md create mode 100644 docs/battery.schema.json create mode 100755 scripts/battery.sh create mode 100644 tests/fixtures/battery/pmset-log-batt-uppercase.txt create mode 100644 tests/fixtures/battery/pmset-log-batt.txt create mode 100644 tests/test_battery.py diff --git a/1bite b/1bite index a756f73..107d9c1 100755 --- a/1bite +++ b/1bite @@ -1,6 +1,18 @@ #!/bin/bash set -euo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +if [[ "${BASH_SOURCE[0]}" == "$0" && $# -gt 0 ]]; then + case "$1" in + --battery) + shift + exec /bin/bash "$ROOT/scripts/battery.sh" "$@" + ;; + --install-battery) + shift + exec /bin/bash "$ROOT/scripts/battery.sh" --install-tool "$@" + ;; + esac +fi MODE=install UPDATE=false WITH_SOGOU=false @@ -29,6 +41,8 @@ Powerlevel10k is installed after Oh My Zsh; existing explicit themes are preserv --plan Print the plan; no writes or downloads. --welcome Show the colorful One Bite system card; no writes or downloads. --lock-status Show whether the selected log root is locked and list openers. +--battery Run battery diagnostics; supports --summary/--json/--tsv/--shell/--field. +--install-battery Install ~/.local/bin/1bite-battery; add --update to replace an unchanged managed copy. --update Update managed tools and refresh desktop DMGs (install mode only). --managed-desktop Automatically place desktop apps and configure Kiro hooks (explicit opt-in). --check-updates Report available versions and configuration drift; never upgrade. @@ -69,6 +83,17 @@ check_install_target() { [[ "${version%%.*}" -ge 26 ]] || fail 'Full installation targets macOS Tahoe 26 or newer.' [[ $(id -u) -ne 0 ]] || fail 'Run as your normal user; Homebrew requests sudo when needed.' echo "Target: Apple Silicon, macOS $version" + if ! startup_battery_summary; then echo 'Battery: unavailable; setup will continue.'; fi +} + +startup_battery_summary() { + # Called inside run_session; RUN_DIR belongs to that same subshell. + # shellcheck disable=SC2031 + local summary work_root=${RUN_DIR:-${TMPDIR:-/tmp}} + summary=$(without_session_lock /usr/bin/env TMPDIR="$work_root" /bin/bash "$ROOT/scripts/battery.sh" --summary --no-cache 2>/dev/null || :) + summary=${summary//$'\n'/ } + [[ -n "$summary" ]] || return 1 + printf '%s\n' "$summary" } installer_url() { diff --git a/AGENTS.md b/AGENTS.md index c711d14..0206bed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Read `README.md` and `docs/automation.md` first. This directory is an independen - Load fzf widgets whenever Zsh is interactive. Do not require stdin, stdout, or stderr to be a TTY: Powerlevel10k instant prompt redirects all three while cached startup runs. Support both current `fzf --zsh` output and the shell files shipped by older healthy Homebrew releases; force an interactive shell with all three descriptors redirected and verify the function, ZLE widget, and Ctrl+R binding. Preserve later personal bindings. - Treat the Homebrew `node` formula as the complete baseline Node.js development/runtime environment. Require `node`, `npm`, and `npx`, and exercise an offline local npm build/start lifecycle during disposable-machine verification. - Keep `hexyl` in the default Homebrew formula set as the terminal hex viewer. It must follow the standard install, executable health, inventory, strict verification, and update-check contracts. +- Keep the full battery diagnostic explicit and independent. `--battery` must dispatch before UI/session creation and never affect verification, inventory, maintenance, locks, or result files. A normal install may print only its cache-free `--summary` after the platform checks; close the session-lock descriptor for that read-only probe tree, omit history, and treat missing, partial, or failed probes as non-blocking display information. Use only system Bash 3.2 and built-in macOS probes; immediately reduce their output to the documented allowlist. Cache only normalized timestamps, source, numeric percentages, and derived intervals in a 0700 private directory with 0600 atomic files and symlink refusal. Preserve unknown/modified installed commands; only an explicit battery-tool update may replace bytes still proven by its private receipt. - Give a fresh shell the reviewed built-in Oh My Zsh plugins `git`, `macos`, `vscode`, `web-search`, `extract`, and `tmux`, plus `docker` only when its client is available. Preserve any existing `plugins` array exactly. Keep fzf, AutoJump, and zoxide on their separately verified native integrations so they are not initialized twice by Oh My Zsh plugins. - Keep SSH helpers identity-neutral. `sshkey` may print a selected existing Ed25519, ECDSA or RSA public key and may create a passphrase-free Ed25519 default only when the user explicitly invokes it; it must never overwrite a key. `pubkey` may copy that public key, but neither helper may read SSH config, agents, known hosts, account names or private key contents into setup output. - At the end of an install, explain every remaining DMG task with the current session's exact package path, a safely quoted `open` command, numbered copy/eject/launch steps, application-specific first-launch work, and the matching strict verification command. Save the same guide in the run directory. Derive it only from current-session receipts; do not surface stale downloads or unselected applications. If a later stage fails, still explain packages successfully prepared in that run. diff --git a/Makefile b/Makefile index b4440cc..c2b9819 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ PYTHON ?= python3 -SHELL_FILES := 1bite scripts/verify.sh scripts/docker-smoke.sh scripts/session.sh scripts/welcome.sh scripts/editor.sh scripts/quality.sh apps/qbittorrent/qbt.sh +SHELL_FILES := 1bite scripts/verify.sh scripts/docker-smoke.sh scripts/session.sh scripts/welcome.sh scripts/editor.sh scripts/battery.sh scripts/quality.sh apps/qbittorrent/qbt.sh .PHONY: build test format-check quality check build: diff --git a/README.md b/README.md index 6470783..1667a54 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Run the installer as your normal administrator account, without `sudo`. If `gh` | AI command-line tools | Installs Codex CLI and Kiro CLI from their official installers. Claude Code is opt-in. | | Desktop applications | Downloads official DMGs for Chrome, ChatGPT, and Kiro IDE when the apps are missing. Docker Desktop is opt-in. | | Optional Docker apps | Provides separate, opt-in launchers for useful local services. These apps never start as part of the main installer. | +| Battery diagnostics | Shows a best-effort charge, power, maximum-capacity, and cycle summary beside the initial macOS check. The independent dashboard also provides JSON, TSV, Shell, and single-field output. Probe failure never changes installation success. | Docker Desktop is disabled by default because its terms may not fit every workplace. Review the current vendor terms, then add `--with-docker` whenever you install, update, or verify it. Claude Desktop and Claude Code are also disabled by default and use `--with-claude`. @@ -58,6 +59,10 @@ The default run checks Doubao Input Method independently after the managed Pytho ./1bite --update # Update managed tools and refresh DMGs ./1bite --welcome # Show the colorful One Bite system card ./1bite --lock-status # Inspect the session lock and its open processes +./1bite --battery # Show the independent battery dashboard +./1bite --battery --summary # Print the cache-free startup summary +./1bite --battery --json # Emit the stable battery report schema +./1bite --install-battery # Optionally install the 1bite-battery command ./1bite --verify # Strictly verify actual installations ./1bite --check-updates # Report available updates without changing them ./1bite --configure-only # Apply managed configuration templates only @@ -98,6 +103,7 @@ One Bite installs both `neofetch` and `fastfetch`. Because Neofetch was archived - [Installation policy](docs/installation.md) - [Shell, editor, Git, aliases, and terminal configuration](docs/shell-editor.md) - [Obsidian installation and vault starter](docs/obsidian.md) +- [Read-only battery diagnostics](docs/battery.md) - [Troubleshooting](docs/troubleshooting.md) - [Updates and maintenance](docs/maintenance.md) - [Official software sources](docs/sources.md) diff --git a/VERSION b/VERSION index 1750564..5a5831a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.6 +0.0.7 diff --git a/config/repository-files.txt b/config/repository-files.txt index d27da3a..631a55c 100644 --- a/config/repository-files.txt +++ b/config/repository-files.txt @@ -52,6 +52,8 @@ config/zsh/terminal.zsh config/zsh/tools.zsh config/zsh/utilities.zsh docs/automation.md +docs/battery.md +docs/battery.schema.json docs/development.md docs/installation.md docs/maintenance.md @@ -61,6 +63,7 @@ docs/shell-editor.md docs/sources.md docs/troubleshooting.md docs/updates.schema.json +scripts/battery.sh scripts/check-change.py scripts/check-format.py scripts/check-repository.py @@ -82,7 +85,10 @@ scripts/runtime-smoke.py scripts/session.sh scripts/welcome.sh scripts/verify.sh +tests/fixtures/battery/pmset-log-batt-uppercase.txt +tests/fixtures/battery/pmset-log-batt.txt tests/fixtures/config-golden.json +tests/test_battery.py tests/test_desktop.py tests/test_doubao.py tests/test_editor.py diff --git a/docs/automation.md b/docs/automation.md index 8c3bdb9..dd6df3e 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -8,6 +8,8 @@ Run Bash 3.2-compatible `./1bite`. Source checkout: `https://github.com/1set/1bi | --- | --- | --- | | `--help`, `--plan` | None, no log directory created | Help/plan printed, exit 0 | | `--lock-status` | None; reads the selected log root and kernel lock | Reports available, held, or lock-helper error; a held lock returns 75 | +| `--battery [OUTPUT]` | Reads allowlisted macOS battery fields; optionally writes only sanitized private history | Produces the requested dashboard, one-line summary, JSON v1, TSV, Shell, or single-field report without entering an install session | +| `--install-battery [--update]` | Explicitly installs or updates the separately owned `1bite-battery` command | Missing target is installed; current managed bytes are skipped; replacement requires an unchanged receipt plus explicit update | | no mode | Install development tools/AI CLIs; prepare missing desktop DMGs and the Doubao Input Method ZIP; apply templates | Automated tools and prepared packages verify; manual_steps lists remaining user work | | `--managed-desktop` | Also place desktop apps and configure managed Kiro Zsh integration | All declared packages/runtime probes pass; GUI/login remain manual | | `--with-docker` | Include Docker Desktop in the selected install, update, verification, inventory, and maintenance scope | Same mode-specific requirements, now including Docker Desktop; engine execution remains a separate smoke check | @@ -22,6 +24,8 @@ Run Bash 3.2-compatible `./1bite`. Source checkout: `https://github.com/1set/1bi `--config-dir DIR` selects the configuration templates, including all eight `zsh/` modules and the complete `obsidian-vault/` starter; it never changes installer URLs or the package manifest. `--log-dir DIR` selects a local, private log root. All invocations using that root share its lock; do not use different roots to bypass concurrency protection. `--update` and `--with-sogou` can be combined only with install or `--plan`, including both flags together. +Explicit battery dispatch occurs before the normal UI, session directory, fd 9 lock, environment report, inventory, and result handling. A normal install also runs the cache-free, history-free battery summary after its architecture, macOS-version, and user checks. That read-only subprocess closes fd 9. Missing hardware prints `Battery: not present.` and succeeds; partial fields are labeled unavailable in place; command failure or empty output degrades to `Battery: unavailable; setup will continue.` None of these cases can fail the target step or stop later installation work. The summary is display output only: it is not a separate step and does not enter `result.json`. Neither the full diagnostic nor its optional command installation participates in `--verify`, inventory, maintenance, or installation success. The diagnostic uses system Bash 3.2 and built-in macOS commands, filters raw output immediately to an exact field allowlist, and writes only sanitized timestamps, power source, numeric percentages, and derived intervals to its mode-0600 cache. Cache directories are mode 0700; replacements are same-directory atomic renames, and symbolic links are never followed. Unknown files are preserved. See [battery.md](battery.md) and the [JSON schema](battery.schema.json). + Default downloadable desktops are Chrome, ChatGPT and Kiro IDE. Docker Desktop is excluded unless `--with-docker` is explicitly supplied; Claude Desktop and Claude Code CLI use `--with-claude`. `--managed-desktop` alone does not select either optional group. Unselected software is not downloaded, installed, read, repaired, updated, inventoried, or required for verification. Pass the same flag again for reruns, `--verify`, `--configure-only`, and `--check-updates`. Each selected app is inspected independently: a healthy copy of one app suppresses only its own download and never another app's. Missing selected apps get their official DMGs in `~/Downloads/1bite` and a final manual-install reminder. The default install applies the same independent check to Doubao Input Method before preparing its official ZIP; Sogou remains selected only by `--with-sogou` and has a separate check. iTerm2/VS Code remain qualified official casks. Default Kiro CLI invokes its official installer and leaves onboarding/integration to the vendor. See installation.md for the per-app policy. The default stage count is derived from the current package and extension manifests plus the pinned Neofetch and default Doubao check/preparation stage; `--with-docker` adds one stage, `--with-claude` adds two stages, and `--with-sogou` adds one check/preparation stage. Install mode prepares Python first, then independently preserves an installed Doubao or prepares its ZIP; selected Sogou follows the same separate rule before the remaining package, configuration, onboarding, and final-verification stages. A later failure therefore retains any current-session receipts and prints actionable manual steps. Prepared packages are not installed apps or enabled input sources. `result.json` includes `desktop_mode`, `with_docker`, `with_claude`, and `manual_steps`. During default install only, verification may accept a freshly validated prepared DMG for an absent selected desktop; inventory records `null` plus `pending_applications`. Standalone `--verify` remains strict and fails for absent selected apps. `--managed-desktop --verify` additionally validates managed Kiro hooks. Doubao and optional Sogou always remain manual installations in either desktop policy. diff --git a/docs/battery.md b/docs/battery.md new file mode 100644 index 0000000..8b5d89b --- /dev/null +++ b/docs/battery.md @@ -0,0 +1,72 @@ +# Battery diagnostics + +One Bite includes an optional battery diagnostic that is independent of installation, repair, and verification. It reads macOS battery data without changing power settings, applications, or personal configuration. Its result never changes the outcome of `./1bite`, `--verify`, inventory, or update checks. + +## Run it + +The repository copy is always available: + +```bash +./1bite --battery +./1bite --battery --summary +./1bite --battery --json +./1bite --battery --tsv +./1bite --battery --shell +./1bite --battery --field battery.charge_percent +``` + +The first command prints a human dashboard. `--summary` prints one line containing current charge, power and charging state, maximum capacity, and equivalent full cycles. It never reads `pmset -g log` or reads or writes the cache. JSON uses the stable [version 1 schema](battery.schema.json). TSV begins with `schema_version`, `section`, `index`, `key`, `value`, and `unit` columns. Shell output is a source-safe initialization fragment containing quoted `ONE_BITE_BATTERY_*` assignments and indexed interval fields; it clears interval variables left by a previously sourced report. The single-field interface accepts only documented scalar names; an unknown name exits 2, and an unavailable value exits 3 without printing a placeholder. + +During a normal installation, the first target step prints the same summary after validating Apple Silicon, the macOS version, and the invoking user. The probe tree does not inherit the installation lock. Missing battery hardware, partial fields, probe failure, or empty output cannot fail that step or stop the following network and installation work. This display does not create a separate progress step, cache history, install the standalone command, or change `result.json`. + +Allowed single fields are: + +- `schema_version`, `collected_at`, `status`, and `complete` +- `battery.present`, `battery.charge_percent`, `battery.power_source`, `battery.state`, `battery.cycle_count`, `battery.maximum_capacity_percent`, `battery.maximum_capacity_mah`, `battery.design_capacity_mah`, `battery.health`, `battery.voltage_mv`, and `battery.current_ma` +- `history.retained_event_count` and `history.interval_count` + +Use `--no-cache` with any report format to avoid reading or writing retained history. Current macOS log data can still produce intervals for that invocation. + +## Optional command installation + +Install the same script as a short standalone command only when wanted: + +```bash +./1bite --install-battery +1bite-battery +``` + +This operation is separate from the main installer. It creates `~/.local/bin/1bite-battery` only when the path is absent. A byte-identical, executable managed copy is skipped without changing its timestamp. A healthy older managed copy is preserved until an explicit update: + +```bash +./1bite --install-battery --update +``` + +An update proceeds only when a private receipt still matches the installed bytes. It backs up those managed bytes in the private battery state directory and then atomically replaces the command. A private kernel lock serializes command installation, and an atomic pending record safely completes ownership after interruption between publication and receipt creation. Unknown files, user-modified copies, non-regular paths, damaged ownership records, and symbolic links are preserved and reported instead of overwritten. + +## Data sources and meaning + +The diagnostic uses only tools included with macOS: + +- `pmset -g batt` supplies current charge, power source, and charging state. +- `system_profiler SPPowerDataType` supplies the cycle count, Apple maximum-capacity percentage, and normalized health condition. +- `ioreg -r -c AppleSmartBattery` supplies allowlisted capacity, voltage, and current values, plus fallbacks when a preferred field is absent. +- `pmset -g log` supplies recent `AC` and `Batt`/`BATT` observations. Consecutive observations on the same source collapse into one boundary; source changes close the preceding interval. Short and zero-second plug/unplug intervals are retained. + +All percentages are validated, converted to decimal integers, and range checked before comparison. A cycle count represents **equivalent full cycles**, not the number of times a charger was connected. Interval durations are **wall-clock time** and can include sleep. The displayed interval count covers the retained log/cache window and is **not** a lifetime count of charge or discharge sessions. + +`status=no_battery` is a successful report with no invented history. `status=partial` means current data was usable but one or more allowlisted metrics or history sources were unavailable. `status=unavailable` means the primary `pmset -g batt` probe could not provide a trustworthy battery-presence result, and the command exits 1. Battery wear or a service recommendation is report data, not a command failure. + +## Private history and privacy boundary + +When caching is enabled, sanitized history lives at: + +```text +${XDG_STATE_HOME:-$HOME/.local/state}/1bite/battery/history-v1.tsv +``` + +The battery directory is mode `0700`; cache and receipt files are mode `0600`. Cache replacement uses a private temporary file in the same directory followed by an atomic rename. A recognized but truncated version 1 cache is rebuilt from sanitized observations. A file with an unknown header is treated as user-owned and preserved. Symbolic links in the state path or cache path disable caching rather than being followed. + +The cache contains only normalized timestamps, power source, numeric percentages, and derived interval fields. Raw `pmset`, `system_profiler`, and `ioreg` output is never stored. The allowlist excludes serial numbers, hardware identifiers, hostnames, account names, environment variables, credentials, application data, and personal configuration. Machine-readable warning codes contain no raw source text. + +The repository quality tests use fixed command output and log fixtures. They verify parsers, formats, permissions, atomic replacement, privacy filtering, and ownership behavior. A release still needs a read-only check on disposable Apple Silicon hardware for actual field availability; command-line tests do not assess physical battery quality. diff --git a/docs/battery.schema.json b/docs/battery.schema.json new file mode 100644 index 0000000..8162e78 --- /dev/null +++ b/docs/battery.schema.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/1set/1bite/blob/master/docs/battery.schema.json", + "title": "One Bite battery diagnostic report", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "collected_at", + "status", + "complete", + "battery", + "history", + "warning_codes" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "collected_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "enum": [ + "ok", + "partial", + "no_battery", + "unavailable" + ] + }, + "complete": { + "type": "boolean" + }, + "battery": { + "$ref": "#/$defs/battery" + }, + "history": { + "$ref": "#/$defs/history" + }, + "warning_codes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "uniqueItems": true + } + }, + "$defs": { + "nullableNonnegativeInteger": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "battery": { + "type": "object", + "additionalProperties": false, + "required": [ + "present", + "charge_percent", + "power_source", + "state", + "cycle_count", + "maximum_capacity_percent", + "maximum_capacity_mah", + "design_capacity_mah", + "health", + "voltage_mv", + "current_ma" + ], + "properties": { + "present": { + "type": [ + "boolean", + "null" + ] + }, + "charge_percent": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 100 + }, + "power_source": { + "enum": [ + "ac", + "battery", + "unknown", + null + ] + }, + "state": { + "enum": [ + "charging", + "charged", + "discharging", + "not_charging", + "unknown", + null + ] + }, + "cycle_count": { + "$ref": "#/$defs/nullableNonnegativeInteger" + }, + "maximum_capacity_percent": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 100 + }, + "maximum_capacity_mah": { + "$ref": "#/$defs/nullableNonnegativeInteger" + }, + "design_capacity_mah": { + "$ref": "#/$defs/nullableNonnegativeInteger" + }, + "health": { + "enum": [ + "normal", + "service_recommended", + "replace_soon", + "replace_now", + "unknown" + ] + }, + "voltage_mv": { + "$ref": "#/$defs/nullableNonnegativeInteger" + }, + "current_ma": { + "type": [ + "integer", + "null" + ] + } + } + }, + "history": { + "type": "object", + "additionalProperties": false, + "required": [ + "duration_basis", + "may_include_sleep", + "is_lifetime", + "retained_event_count", + "interval_count", + "intervals" + ], + "properties": { + "duration_basis": { + "const": "wall_clock" + }, + "may_include_sleep": { + "const": true + }, + "is_lifetime": { + "const": false + }, + "retained_event_count": { + "type": "integer", + "minimum": 0 + }, + "interval_count": { + "type": "integer", + "minimum": 0 + }, + "intervals": { + "type": "array", + "items": { + "$ref": "#/$defs/interval" + } + } + } + }, + "interval": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "start_at", + "end_at", + "start_percent", + "end_percent", + "delta_percent", + "duration_seconds", + "complete" + ], + "properties": { + "source": { + "enum": [ + "ac", + "battery" + ] + }, + "start_at": { + "type": "string", + "format": "date-time" + }, + "end_at": { + "type": "string", + "format": "date-time" + }, + "start_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "end_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "delta_percent": { + "type": "integer", + "minimum": -100, + "maximum": 100 + }, + "duration_seconds": { + "type": "integer", + "minimum": 0 + }, + "complete": { + "type": "boolean" + } + } + } + } +} diff --git a/docs/development.md b/docs/development.md index f00b24d..3a1d5a7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,6 +9,7 @@ This document is the short entry point for contributors. Product behavior and co - Use the system Zsh and do not change the login shell. - Preserve personal configuration, credentials, unknown installations, and software managed by another tool. - Treat download receipts and pending records as ownership metadata, never as proof that software is healthy. +- Keep full battery diagnostics outside install sessions, locks, verification, inventory, and result semantics. The normal install may reuse only the cache-free, history-free summary in its initial target step; close fd 9 for its subprocess tree and make every probe failure non-blocking. Stream macOS probe output through exact allowlists and never retain raw hardware reports. ## Repository layout @@ -47,6 +48,8 @@ This in-repository gate is intentionally isolated. It must not run `./1bite`, in Changes to installation logic should cover a fresh install, a healthy repeat, an explicit update, managed damage repair, and preservation of an unknown or personal installation. Changes to shell files should also cover an existing `.zshrc`, canonicalization of recognized loader variants, duplicate collapse, unchanged repeat runs, and real interactive Zsh behavior with the declared tools installed. +Battery changes require fixed fixtures for both `Batt` and `BATT` log forms, optional percent spacing, charging/discharging transitions, sleep-spanning wall time, instantaneous source changes, no battery, and cache damage. Assert decimal numeric comparison, the complete summary/JSON/TSV/Shell/field contracts, output/cache privacy, 0700/0600 modes, atomic replacement, and symlink refusal. Startup integration tests must prove that the summary skips history and cache, closes the session lock, does not become a progress/result step, and cannot hide or create a later installation failure. The optional installed command must preserve unknown or modified bytes and replace an unchanged managed copy only after an explicit update. These isolated tests do not establish physical battery health. + Default desktop mode must download and validate selected DMGs without placing `.app` bundles. Its final guide must be derived from the current run's receipts, exclude old or unselected packages, save `manual-steps.txt` with mode `0600`, and print a strict follow-up verification command. Managed desktop mode has separate placement and Kiro integration checks. Full installation tests belong on disposable macOS runners because they can install Homebrew packages and replace runner applications. Local development and the repository quality workflow use unit tests, archive checks, and isolated temporary homes. @@ -58,6 +61,7 @@ Full installation tests belong on disposable macOS runners because they can inst - [installation.md](installation.md) explains each package manager and desktop policy. - [shell-editor.md](shell-editor.md) defines managed templates and configuration ownership. - [obsidian.md](obsidian.md) defines the portable starter, bundled theme, and vault ownership boundary. +- [battery.md](battery.md) defines the read-only probe, report schemas, cache, and optional command ownership. - [troubleshooting.md](troubleshooting.md) contains user-facing recovery procedures. Keep public documentation free of personal paths, account details, internal repositories, credentials, test artifacts, and local release records. diff --git a/docs/installation.md b/docs/installation.md index 965fff8..c8d5b10 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -19,6 +19,7 @@ The default mode keeps vendor installation and first-launch flows for desktop ap | Powerlevel10k | Shallow official checkout in the Oh My Zsh custom theme directory | Same | Selected only when the user has no explicit theme. Run `p10k configure` in a new One Bite profile window; the user owns the resulting `~/.p10k.zsh`. | | MesloLGS and JetBrains Mono Nerd Font | Homebrew font casks | Same | Verifies each regular font face. The managed One Bite iTerm2 profile selects MesloLGS; unrelated and already-open profiles are unchanged. | | hexyl | Homebrew formula | Same | Provides a colored terminal hex viewer for inspecting binary files. It follows the standard install, repair, inventory, verification, and update lifecycle. | +| Battery diagnostic | Prints a best-effort, cache-free summary during the initial target check; not installed | Same | The summary omits history, closes the session lock, and never blocks setup. `./1bite --battery` remains the independent full report. `./1bite --install-battery` explicitly installs its optional standalone command. | | LazyGit | Homebrew formula | Same | Provides `lazygit` and the `lg` alias. Normal reruns skip it; `--update` uses Homebrew. | | Fastfetch | Maintained Homebrew formula | Same | Provides a current, fast system-information view. | | Neofetch | Official 7.1.0 script pinned by SHA-256 | Same fixed release | Upstream is archived and absent from current Homebrew. A healthy existing command is preserved; an unknown broken command is never overwritten. | diff --git a/scripts/battery.sh b/scripts/battery.sh new file mode 100755 index 0000000..a88ac1a --- /dev/null +++ b/scripts/battery.sh @@ -0,0 +1,1710 @@ +#!/bin/bash +set -euo pipefail + +BATTERY_TOOL_VERSION=0.0.7 +BATTERY_SCHEMA_VERSION=1 +MAX_RETAINED_EVENTS=256 + +OUTPUT_FORMAT=dashboard +FIELD_NAME= +CACHE_ENABLED=true +INSTALL_TOOL=false +UPDATE_TOOL=false + +PMSET_BIN=${ONE_BITE_BATTERY_PMSET_BIN:-/usr/bin/pmset} +SYSTEM_PROFILER_BIN=${ONE_BITE_BATTERY_SYSTEM_PROFILER_BIN:-/usr/sbin/system_profiler} +IOREG_BIN=${ONE_BITE_BATTERY_IOREG_BIN:-/usr/sbin/ioreg} +DATE_BIN=${ONE_BITE_BATTERY_DATE_BIN:-/bin/date} +SCRIPT_PATH=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}") + +usage() { + cat <<'HELP' +Usage: 1bite-battery [--summary|--json|--tsv|--shell|--field NAME] [--no-cache] + +Read-only macOS battery diagnostics. The default output is a human dashboard. + --summary Print one startup-safe line without history or cache access. + --json Print stable JSON schema version 1. + --tsv Print normalized, versioned TSV rows. + --shell Print source-safe shell assignments. + --field NAME Print one allowlisted scalar field. + --no-cache Do not read or write sanitized battery history. + --help Show this help. + +One Bite uses the internal --install-tool [--update] operation to install this +script as ~/.local/bin/1bite-battery. It is separate from the main installer. +HELP +} + +die_usage() { + printf '1bite-battery: %s\n' "$*" >&2 + usage >&2 + exit 2 +} + +set_output_format() { + [[ "$OUTPUT_FORMAT" == dashboard ]] || die_usage 'choose only one output format' + OUTPUT_FORMAT=$1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --json) + set_output_format json + shift + ;; + --summary) + set_output_format summary + CACHE_ENABLED=false + shift + ;; + --tsv) + set_output_format tsv + shift + ;; + --shell) + set_output_format shell + shift + ;; + --field) + [[ $# -ge 2 && -n "$2" ]] || die_usage '--field requires a name' + set_output_format field + FIELD_NAME=$2 + shift 2 + ;; + --format) + [[ $# -ge 2 ]] || die_usage '--format requires dashboard, summary, json, tsv, or shell' + case "$2" in dashboard | summary | json | tsv | shell) ;; *) die_usage "unknown format: $2" ;; esac + set_output_format "$2" + if [[ "$2" == summary ]]; then CACHE_ENABLED=false; fi + shift 2 + ;; + --no-cache) + CACHE_ENABLED=false + shift + ;; + --install-tool) + [[ "$INSTALL_TOOL" == false ]] || die_usage '--install-tool was provided twice' + INSTALL_TOOL=true + shift + ;; + --update) + UPDATE_TOOL=true + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) die_usage "unknown argument: $1" ;; + esac +done + +[[ "$UPDATE_TOOL" == false || "$INSTALL_TOOL" == true ]] || die_usage '--update is valid only with --install-tool' +if [[ "$INSTALL_TOOL" == true && "$OUTPUT_FORMAT" != dashboard ]]; then + die_usage '--install-tool cannot be combined with an output format' +fi +BATTERY_FIELD_NAMES=' schema_version collected_at status complete battery.present battery.charge_percent battery.power_source battery.state battery.cycle_count battery.maximum_capacity_percent battery.maximum_capacity_mah battery.design_capacity_mah battery.health battery.voltage_mv battery.current_ma history.retained_event_count history.interval_count ' +if [[ "$OUTPUT_FORMAT" == field ]]; then + case "$BATTERY_FIELD_NAMES" in + *" $FIELD_NAME "*) ;; + *) + printf '1bite-battery: unknown field: %s\n' "$FIELD_NAME" >&2 + exit 2 + ;; + esac +fi + +umask 077 +WARNINGS= + +add_warning() { + case " $WARNINGS " in + *" $1 "*) ;; + *) WARNINGS="${WARNINGS:+$WARNINGS }$1" ;; + esac +} + +path_has_symlink() { + local path=$1 current='' part + local old_ifs=$IFS + local -a parts + if [[ "$path" != /* ]]; then path="$PWD/$path"; fi + IFS=/ read -r -a parts <<<"${path#/}" + IFS=$old_ifs + current=/ + for part in "${parts[@]}"; do + [[ -n "$part" ]] || continue + if [[ "$current" == / ]]; then current="/$part"; else current="$current/$part"; fi + if [[ -L "$current" ]]; then + return 0 + fi + done + return 1 +} + +normalize_unsigned() { + local value=$1 maximum=$2 + case "$value" in '' | *[!0-9]*) return 1 ;; esac + while [[ ${#value} -gt 1 && ${value:0:1} == 0 ]]; do value=${value:1}; done + if [[ ${#value} -gt ${#maximum} ]]; then return 1; fi + if [[ ${#value} -eq ${#maximum} && "$value" > "$maximum" ]]; then return 1; fi + printf '%s\n' "$value" +} + +decimal_subtract() { + local larger=$1 smaller=$2 result='' borrow=0 index left right digit + index=$((${#larger} - 1)) + while [[ $index -ge 0 ]]; do + left=${larger:index:1} + if [[ $index -lt $((${#larger} - ${#smaller})) ]]; then + right=0 + else + right=${smaller:index-(${#larger} - ${#smaller}):1} + fi + digit=$((left - right - borrow)) + if [[ $digit -lt 0 ]]; then + digit=$((digit + 10)) + borrow=1 + else + borrow=0 + fi + result="$digit$result" + index=$((index - 1)) + done + while [[ ${#result} -gt 1 && ${result:0:1} == 0 ]]; do result=${result:1}; done + printf '%s\n' "$result" +} + +utc_iso_epoch() { + LC_ALL=C awk -v value="$1" ' + function leap(year) { return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } + function dim(year, month) { + if (month == 2) return 28 + leap(year) + if (month == 4 || month == 6 || month == 9 || month == 11) return 30 + return 31 + } + BEGIN { + if (length(value) != 20 || substr(value,5,1) != "-" || substr(value,8,1) != "-" || + substr(value,11,1) != "T" || substr(value,14,1) != ":" || substr(value,17,1) != ":" || + substr(value,20,1) != "Z" || substr(value,1,4) !~ /^[0-9][0-9][0-9][0-9]$/ || + substr(value,6,2) !~ /^[0-9][0-9]$/ || substr(value,9,2) !~ /^[0-9][0-9]$/ || + substr(value,12,2) !~ /^[0-9][0-9]$/ || substr(value,15,2) !~ /^[0-9][0-9]$/ || + substr(value,18,2) !~ /^[0-9][0-9]$/) exit 1 + year=substr(value,1,4)+0; month=substr(value,6,2)+0; day=substr(value,9,2)+0 + hour=substr(value,12,2)+0; minute=substr(value,15,2)+0; second=substr(value,18,2)+0 + if (year < 1970 || month < 1 || month > 12 || day < 1 || day > dim(year,month) || + hour > 23 || minute > 59 || second > 59) exit 1 + days=0 + for (cursor=1970; cursor x4294947295 && "x$value" < x4294967296 ]]; then + magnitude=$(decimal_subtract 4294967296 "$value") + magnitude=$(normalize_unsigned "$magnitude" 20000) || return 1 + printf -- '-%s\n' "$magnitude" + return + fi + fi + if [[ ${#value} -eq 20 && ("x$value" > x18446744073709531615 || "$value" == 18446744073709531615) && ("x$value" < x18446744073709551616) ]]; then + magnitude=$(decimal_subtract 18446744073709551616 "$value") + magnitude=$(normalize_unsigned "$magnitude" 20000) || return 1 + printf -- '-%s\n' "$magnitude" + return + fi + return 1 +} + +sha256_file() { + if [[ -x /usr/bin/shasum ]]; then + /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | /usr/bin/awk '{print $1}' + else + printf '1bite-battery: system shasum is required for managed installation.\n' >&2 + return 1 + fi +} + +safe_absolute_path() { + [[ "$1" == /* ]] || return 1 + case "$1" in + */./* | */../* | */. | */..) return 1 ;; + esac + return 0 +} + +file_mode() { + if [[ "$(/usr/bin/uname -s)" == Darwin ]]; then + /usr/bin/stat -f '%Lp' "$1" + else + /usr/bin/stat -c '%a' "$1" + fi +} + +file_link_count() { + if [[ "$(/usr/bin/uname -s)" == Darwin ]]; then + /usr/bin/stat -f '%l' "$1" + else + /usr/bin/stat -c '%h' "$1" + fi +} + +directory_identity() { + if [[ "$(/usr/bin/uname -s)" == Darwin ]]; then + /usr/bin/stat -f '%d:%i' "$1" + else + /usr/bin/stat -c '%d:%i' "$1" + fi +} + +directory_matches() { + local path=$1 expected_identity=$2 expected_real=$3 actual_real + [[ -d "$path" && ! -L "$path" ]] || return 1 + path_has_symlink "$path" && return 1 + [[ "$(directory_identity "$path")" == "$expected_identity" ]] || return 1 + actual_real=$(cd "$path" && pwd -P) || return 1 + [[ "$actual_real" == "$expected_real" ]] +} + +private_regular_file() { + [[ -f "$1" && ! -L "$1" ]] || return 1 + [[ "$(file_mode "$1")" == 600 && "$(file_link_count "$1")" == 1 ]] +} + +ensure_private_battery_dir() { + local state_base=${XDG_STATE_HOME:-$HOME/.local/state} + PRIVATE_ROOT="$state_base/1bite/battery" + PRIVATE_DIR_READY=false + if ! safe_absolute_path "$state_base"; then + add_warning state_path_unsafe + return 0 + fi + if path_has_symlink "$state_base" || path_has_symlink "${state_base%/}/1bite" || path_has_symlink "$PRIVATE_ROOT"; then + add_warning state_path_symlink + return 0 + fi + if ! mkdir -p "$PRIVATE_ROOT" 2>/dev/null; then + add_warning state_path_unsafe + return 0 + fi + if path_has_symlink "$PRIVATE_ROOT" || [[ ! -d "$PRIVATE_ROOT" ]]; then + add_warning state_path_unsafe + return 0 + fi + if ! chmod 700 "$PRIVATE_ROOT" 2>/dev/null; then + add_warning state_path_unsafe + return 0 + fi + PRIVATE_ROOT_IDENTITY=$(directory_identity "$PRIVATE_ROOT") || { + add_warning state_path_unsafe + return 0 + } + PRIVATE_ROOT_REAL=$(cd "$PRIVATE_ROOT" && pwd -P) || { + add_warning state_path_unsafe + return 0 + } + PRIVATE_DIR_READY=true +} + +acquire_tool_lock() { + local lock_file="$PRIVATE_ROOT/tool.lock" lock_status + directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || { + printf '1bite-battery: private state directory changed before locking.\n' >&2 + exit 1 + } + if [[ ! -e "$lock_file" && ! -L "$lock_file" ]]; then + ( + set -o noclobber + : >"$lock_file" + ) 2>/dev/null || true + fi + if ! private_regular_file "$lock_file"; then + printf '1bite-battery: preserving an unsafe or non-regular tool lock.\n' >&2 + exit 1 + fi + exec 8>>"$lock_file" + directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || { + exec 8>&- + printf '1bite-battery: private state directory changed while locking.\n' >&2 + exit 1 + } + set +e + if [[ -x /usr/bin/lockf ]]; then + /usr/bin/lockf -s -t 0 8 + lock_status=$? + elif command -v flock >/dev/null 2>&1; then + flock -n -E 75 8 + lock_status=$? + else + lock_status=69 + fi + set -e + if [[ $lock_status -eq 75 ]]; then + printf '1bite-battery: another battery-tool install or update is running.\n' >&2 + exit 75 + elif [[ $lock_status -ne 0 ]]; then + printf '1bite-battery: the battery-tool lock helper failed (%s).\n' "$lock_status" >&2 + exit "$lock_status" + fi +} + +publish_record() { + local path=$1 header=$2 version=$3 digest=$4 expected_sha=$5 temporary displaced='' record_sha + if ! temporary=$(mktemp "${path%/*}/.${path##*/}.XXXXXX"); then return 1; fi + if ! printf '%s\nversion\t%s\nsha256\t%s\n' "$header" "$version" "$digest" >"$temporary" || + ! chmod 600 "$temporary" || ! private_regular_file "$temporary"; then + rm -f "$temporary" + return 1 + fi + if ! record_sha=$(sha256_file "$temporary"); then + rm -f "$temporary" + return 1 + fi + directory_matches "${path%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || { + rm -f "$temporary" + return 1 + } + if [[ -n "$expected_sha" ]]; then + if ! private_regular_file "$path" || [[ "$(sha256_file "$path")" != "$expected_sha" ]]; then + rm -f "$temporary" + return 1 + fi + if ! displaced=$(mktemp "${path}.previous.XXXXXX") || ! rm -f "$displaced"; then + rm -f "$temporary" + return 1 + fi + if ! mv -f "$path" "$displaced" || ! private_regular_file "$displaced" || + [[ "$(sha256_file "$displaced")" != "$expected_sha" ]]; then + if [[ -e "$displaced" || -L "$displaced" ]]; then mv -n "$displaced" "$path" || true; fi + rm -f "$temporary" + return 1 + fi + if ! directory_matches "${path%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL"; then + mv -n "$displaced" "$path" || true + rm -f "$temporary" + return 1 + fi + elif [[ -e "$path" || -L "$path" ]]; then + rm -f "$temporary" + return 1 + fi + if ! mv -n "$temporary" "$path"; then + if [[ -n "$displaced" && (-e "$displaced" || -L "$displaced") ]]; then mv -n "$displaced" "$path" || true; fi + rm -f "$temporary" + return 1 + fi + if ! directory_matches "${path%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL"; then return 1; fi + if [[ -e "$temporary" || -L "$temporary" ]]; then + rm -f "$temporary" + if [[ -n "$displaced" && (-e "$displaced" || -L "$displaced") ]]; then mv -n "$displaced" "$path" || true; fi + return 1 + fi + if ! private_regular_file "$path" || [[ "$(sha256_file "$path")" != "$record_sha" ]]; then + if [[ -f "$path" && ! -L "$path" && "$(file_link_count "$path")" == 1 && "$(sha256_file "$path")" == "$record_sha" ]]; then + rm -f "$path" + fi + if [[ -n "$displaced" && (-e "$displaced" || -L "$displaced") ]]; then mv -n "$displaced" "$path" || true; fi + return 1 + fi + if [[ -n "$displaced" ]] && ! rm -f "$displaced"; then return 1; fi + return 0 +} + +read_receipt() { + local receipt=$1 header key1 value1 extra1 key2 value2 extra2 trailing='' + RECEIPT_VALID=false + RECEIPT_VERSION= + RECEIPT_SHA= + RECEIPT_FILE_SHA= + directory_matches "${receipt%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || return 0 + private_regular_file "$receipt" || return 0 + { + IFS= read -r header || return 0 + IFS=$'\t' read -r key1 value1 extra1 || return 0 + IFS=$'\t' read -r key2 value2 extra2 || return 0 + if IFS= read -r trailing || [[ -n "$trailing" ]]; then return 0; fi + } <"$receipt" + [[ "$header" == $'one-bite-battery-tool\t1' && "$key1" == version && "$key2" == sha256 && -z "$extra1" && -z "$extra2" ]] || return 0 + RECEIPT_VERSION=$value1 + RECEIPT_SHA=$value2 + [[ "$RECEIPT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$RECEIPT_SHA" =~ ^[0-9a-f]{64}$ ]] || return 0 + RECEIPT_FILE_SHA=$(sha256_file "$receipt") + RECEIPT_VALID=true +} + +write_pending() { + if ! publish_record "$1" $'one-bite-battery-tool-pending\t1' "$BATTERY_TOOL_VERSION" "$2" ''; then + printf '1bite-battery: preserving an existing pending ownership record.\n' >&2 + return 1 + fi +} + +restore_pending() { + local pending=$1 version=$2 digest=$3 + directory_matches "${pending%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || return 1 + [[ ! -e "$pending" && ! -L "$pending" ]] || return 1 + publish_record "$pending" $'one-bite-battery-tool-pending\t1' "$version" "$digest" '' +} + +read_pending() { + local pending=$1 header key1 value1 extra1 key2 value2 extra2 trailing='' + PENDING_VALID=false + PENDING_VERSION= + PENDING_SHA= + PENDING_FILE_SHA= + directory_matches "${pending%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || return 0 + private_regular_file "$pending" || return 0 + { + IFS= read -r header || return 0 + IFS=$'\t' read -r key1 value1 extra1 || return 0 + IFS=$'\t' read -r key2 value2 extra2 || return 0 + if IFS= read -r trailing || [[ -n "$trailing" ]]; then return 0; fi + } <"$pending" + [[ "$header" == $'one-bite-battery-tool-pending\t1' && "$key1" == version && "$key2" == sha256 && -z "$extra1" && -z "$extra2" ]] || return 0 + PENDING_VERSION=$value1 + PENDING_SHA=$value2 + [[ "$PENDING_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$PENDING_SHA" =~ ^[0-9a-f]{64}$ ]] || return 0 + PENDING_FILE_SHA=$(sha256_file "$pending") + PENDING_VALID=true +} + +remove_owned_record() { + local path=$1 expected_sha=$2 displaced + directory_matches "${path%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || return 1 + private_regular_file "$path" || return 1 + [[ "$(sha256_file "$path")" == "$expected_sha" ]] || return 1 + if ! displaced=$(mktemp "${path}.removed.XXXXXX") || ! rm -f "$displaced"; then return 1; fi + if ! mv -f "$path" "$displaced"; then return 1; fi + if ! directory_matches "${path%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL"; then + mv -n "$displaced" "$path" || true + return 1 + fi + if ! private_regular_file "$displaced" || [[ "$(sha256_file "$displaced")" != "$expected_sha" ]]; then + mv -n "$displaced" "$path" || true + return 1 + fi + directory_matches "${path%/*}" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || { + mv -n "$displaced" "$path" || true + return 1 + } + rm -f "$displaced" || return 1 + return 0 +} + +verify_managed_tool_state() { + local target=$1 receipt=$2 pending=$3 expected_version=$4 expected_sha=$5 + local bin_dir=$6 expected_bin_identity=$7 expected_bin_real=$8 first_receipt_sha first_target_sha final_target_sha link_count + directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || return 1 + directory_matches "$bin_dir" "$expected_bin_identity" "$expected_bin_real" || return 1 + [[ ! -e "$pending" && ! -L "$pending" ]] || return 1 + read_receipt "$receipt" + [[ "$RECEIPT_VALID" == true && "$RECEIPT_VERSION" == "$expected_version" && "$RECEIPT_SHA" == "$expected_sha" ]] || return 1 + first_receipt_sha=$RECEIPT_FILE_SHA + first_target_sha=$(sha256_file "$target") || return 1 + [[ "$first_target_sha" == "$expected_sha" ]] || return 1 + /bin/bash -n "$target" >/dev/null 2>&1 || return 1 + link_count=$(file_link_count "$target") || return 1 + directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || return 1 + directory_matches "$bin_dir" "$expected_bin_identity" "$expected_bin_real" || return 1 + read_receipt "$receipt" + [[ "$RECEIPT_VALID" == true && "$RECEIPT_VERSION" == "$expected_version" && "$RECEIPT_SHA" == "$expected_sha" && + "$RECEIPT_FILE_SHA" == "$first_receipt_sha" ]] || return 1 + final_target_sha=$(sha256_file "$target") || return 1 + [[ "$final_target_sha" == "$expected_sha" && "$link_count" == 1 && + -d "$PRIVATE_ROOT" && ! -L "$PRIVATE_ROOT" && -d "$bin_dir" && ! -L "$bin_dir" && + ! -e "$pending" && ! -L "$pending" && -f "$receipt" && ! -L "$receipt" && + -f "$target" && ! -L "$target" && -x "$target" ]] +} + +install_tool() { + local bin_dir="$HOME/.local/bin" target="$HOME/.local/bin/1bite-battery" + local receipt pending receipt_exists=false pending_exists=false current_sha target_sha='' temporary temporary_sha source_sha_after backup='' backup_incomplete='' backup_name timestamp target_healthy=false displaced='' bin_identity bin_real recovered=false recovery_sha='' recovery_version='' recovery_file_sha='' success_version='' + if ! safe_absolute_path "$HOME"; then + printf '1bite-battery: HOME is not a safe absolute path; nothing was installed.\n' >&2 + exit 1 + fi + ensure_private_battery_dir + if [[ "$PRIVATE_DIR_READY" != true ]]; then + printf '1bite-battery: private state directory is unsafe; nothing was installed.\n' >&2 + exit 1 + fi + acquire_tool_lock + receipt="$PRIVATE_ROOT/tool-receipt-v1.tsv" + pending="$PRIVATE_ROOT/tool-pending-v1.tsv" + if path_has_symlink "$bin_dir" || [[ -L "$target" || -L "$receipt" || -L "$pending" ]]; then + printf '1bite-battery: refusing to follow a symlink in the managed tool path.\n' >&2 + exit 1 + fi + mkdir -p "$bin_dir" + if [[ ! -d "$bin_dir" || -L "$bin_dir" ]] || path_has_symlink "$bin_dir"; then + printf '1bite-battery: ~/.local/bin is not a safe directory.\n' >&2 + exit 1 + fi + bin_identity=$(directory_identity "$bin_dir") + bin_real=$(cd "$bin_dir" && pwd -P) + current_sha=$(sha256_file "$SCRIPT_PATH") + if [[ -e "$receipt" ]]; then receipt_exists=true; fi + if [[ -e "$pending" ]]; then pending_exists=true; fi + read_receipt "$receipt" + read_pending "$pending" + if [[ "$receipt_exists" == true && "$RECEIPT_VALID" != true ]]; then + printf '1bite-battery: preserving the existing unknown or damaged receipt; nothing was installed.\n' >&2 + exit 1 + fi + if [[ "$pending_exists" == true && "$PENDING_VALID" != true ]]; then + printf '1bite-battery: preserving the existing unknown or damaged pending ownership record; nothing was installed.\n' >&2 + exit 1 + fi + if [[ -e "$target" ]]; then + [[ -f "$target" && ! -L "$target" && "$(file_link_count "$target")" == 1 ]] || { + printf '1bite-battery: preserving the existing non-regular %s.\n' "$target" >&2 + exit 1 + } + target_sha=$(sha256_file "$target") + if /bin/bash -n "$target" >/dev/null 2>&1 && [[ -x "$target" ]]; then target_healthy=true; fi + fi + if [[ "$PENDING_VALID" == true ]]; then + if [[ -n "$target_sha" && "$target_sha" == "$PENDING_SHA" && "$target_healthy" == true ]]; then + recovery_sha=$PENDING_SHA + recovery_version=$PENDING_VERSION + recovery_file_sha=$PENDING_FILE_SHA + if ! publish_record "$receipt" $'one-bite-battery-tool\t1' "$PENDING_VERSION" "$PENDING_SHA" "$RECEIPT_FILE_SHA"; then + printf '1bite-battery: the ownership receipt changed during recovery; it was preserved.\n' >&2 + exit 1 + fi + read_receipt "$receipt" + if ! directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || + ! directory_matches "$bin_dir" "$bin_identity" "$bin_real" || + [[ "$RECEIPT_VALID" != true || "$RECEIPT_VERSION" != "$recovery_version" || "$RECEIPT_SHA" != "$recovery_sha" || + -L "$target" || ! -f "$target" || ! -x "$target" || "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$recovery_sha" ]] || + ! /bin/bash -n "$target" >/dev/null 2>&1; then + printf '1bite-battery: managed paths changed during ownership recovery; the pending record was retained.\n' >&2 + exit 1 + fi + if ! remove_owned_record "$pending" "$recovery_file_sha"; then + printf '1bite-battery: the pending ownership record changed during recovery; it was preserved.\n' >&2 + exit 1 + fi + read_receipt "$receipt" + if ! directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || + ! directory_matches "$bin_dir" "$bin_identity" "$bin_real" || + [[ "$RECEIPT_VALID" != true || "$RECEIPT_VERSION" != "$recovery_version" || "$RECEIPT_SHA" != "$recovery_sha" || + -e "$pending" || -L "$pending" || -L "$target" || ! -f "$target" || ! -x "$target" || + "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$recovery_sha" ]] || + ! /bin/bash -n "$target" >/dev/null 2>&1; then + restore_pending "$pending" "$recovery_version" "$recovery_sha" || true + printf '1bite-battery: managed paths changed while finishing ownership recovery; success was not recorded.\n' >&2 + exit 1 + fi + PENDING_VALID=false + recovered=true + elif [[ -z "$target_sha" ]]; then + if ! remove_owned_record "$pending" "$PENDING_FILE_SHA"; then + printf '1bite-battery: the pending ownership record changed; it was preserved.\n' >&2 + exit 1 + fi + PENDING_VALID=false + elif [[ "$RECEIPT_VALID" == true && "$target_sha" == "$RECEIPT_SHA" && "$UPDATE_TOOL" == true ]]; then + if ! remove_owned_record "$pending" "$PENDING_FILE_SHA"; then + printf '1bite-battery: the pending ownership record changed; it was preserved.\n' >&2 + exit 1 + fi + PENDING_VALID=false + else + printf '1bite-battery: an interrupted battery-tool operation needs the same --update scope or manual review.\n' >&2 + exit 1 + fi + fi + if [[ -n "$target_sha" ]]; then + if [[ "$target_sha" == "$current_sha" && "$target_healthy" == true ]]; then + if ! directory_matches "$bin_dir" "$bin_identity" "$bin_real" || + [[ -L "$target" || ! -f "$target" || ! -x "$target" || "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$current_sha" ]] || + ! /bin/bash -n "$target" >/dev/null 2>&1; then + if [[ "$recovered" == true ]] && directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" && + [[ ! -e "$pending" && ! -L "$pending" ]]; then + write_pending "$pending" "$current_sha" || true + fi + printf '1bite-battery: managed target changed during ownership recovery; success was not recorded.\n' >&2 + exit 1 + fi + if [[ "$RECEIPT_VALID" == true && "$target_sha" == "$RECEIPT_SHA" ]]; then + success_version=$RECEIPT_VERSION + if ! verify_managed_tool_state "$target" "$receipt" "$pending" "$success_version" "$current_sha" "$bin_dir" "$bin_identity" "$bin_real"; then + if [[ "$recovered" == true ]] && directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" && + [[ ! -e "$pending" && ! -L "$pending" ]]; then + restore_pending "$pending" "$recovery_version" "$recovery_sha" || true + fi + printf '1bite-battery: final managed-tool verification failed; success was not reported.\n' >&2 + exit 1 + fi + if [[ "$recovered" == true ]]; then printf 'Recovered ownership for a completely published One Bite battery tool.\n'; fi + printf 'One Bite battery tool is already current: %s\n' "$target" + else + printf '1bite-battery: preserving the existing unowned %s.\n' "$target" >&2 + exit 1 + fi + return 0 + fi + if [[ "$RECEIPT_VALID" != true || "$target_sha" != "$RECEIPT_SHA" ]]; then + printf '1bite-battery: preserving the existing unowned or modified %s.\n' "$target" >&2 + exit 1 + fi + if [[ "$UPDATE_TOOL" != true ]]; then + if [[ "$target_healthy" == true ]]; then + success_version=$RECEIPT_VERSION + if ! verify_managed_tool_state "$target" "$receipt" "$pending" "$success_version" "$target_sha" "$bin_dir" "$bin_identity" "$bin_real"; then + if [[ "$recovered" == true ]] && directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" && + [[ ! -e "$pending" && ! -L "$pending" ]]; then + restore_pending "$pending" "$recovery_version" "$recovery_sha" || true + fi + printf '1bite-battery: final managed-tool verification failed; success was not reported.\n' >&2 + exit 1 + fi + printf 'Preserving managed battery tool %s (%s). Run with --update to replace it.\n' "$target" "$RECEIPT_VERSION" + return 0 + fi + printf '1bite-battery: the managed battery tool is damaged; rerun with --update to replace it.\n' >&2 + exit 1 + fi + timestamp=$("$DATE_BIN" -u '+%Y%m%dT%H%M%SZ') + if [[ ! "$timestamp" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || + ! directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL"; then + printf '1bite-battery: backup timestamp or private state path is invalid; nothing was replaced.\n' >&2 + exit 1 + fi + backup_incomplete=$(mktemp "$PRIVATE_ROOT/.1bite-battery.backup-$timestamp.XXXXXX") + if ! cp -p "$target" "$backup_incomplete" || ! chmod 600 "$backup_incomplete" || + [[ "$(sha256_file "$backup_incomplete")" != "$target_sha" ]]; then + rm -f "$backup_incomplete" + printf '1bite-battery: could not create a verified backup; nothing was replaced.\n' >&2 + exit 1 + fi + backup_name=${backup_incomplete##*/} + backup="$PRIVATE_ROOT/${backup_name#.}" + if ! directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL"; then + printf '1bite-battery: private state directory changed during backup; nothing was replaced.\n' >&2 + exit 1 + fi + if ! mv -n "$backup_incomplete" "$backup"; then + rm -f "$backup_incomplete" + printf '1bite-battery: could not publish the verified backup; nothing was replaced.\n' >&2 + exit 1 + fi + if [[ -e "$backup_incomplete" || ! -f "$backup" || "$(sha256_file "$backup")" != "$target_sha" ]]; then + rm -f "$backup_incomplete" + printf '1bite-battery: could not publish the verified backup; nothing was replaced.\n' >&2 + exit 1 + fi + [[ ! -L "$target" && -f "$target" && "$(file_link_count "$target")" == 1 && "$(sha256_file "$target")" == "$target_sha" ]] || { + printf '1bite-battery: installed command changed during update; nothing was replaced.\n' >&2 + exit 1 + } + fi + write_pending "$pending" "$current_sha" + read_pending "$pending" + if [[ "$PENDING_VALID" != true || "$PENDING_SHA" != "$current_sha" || "$PENDING_VERSION" != "$BATTERY_TOOL_VERSION" ]]; then + printf '1bite-battery: pending ownership verification failed; nothing was replaced.\n' >&2 + exit 1 + fi + temporary=$(mktemp "$bin_dir/.1bite-battery.XXXXXX") + cat "$SCRIPT_PATH" >"$temporary" + chmod 755 "$temporary" + /bin/bash -n "$temporary" + temporary_sha=$(sha256_file "$temporary") + source_sha_after=$(sha256_file "$SCRIPT_PATH") + if [[ "$temporary_sha" != "$current_sha" || "$source_sha_after" != "$current_sha" ]]; then + rm -f "$temporary" + printf '1bite-battery: source bytes changed during installation; nothing was replaced.\n' >&2 + exit 1 + fi + if ! directory_matches "$bin_dir" "$bin_identity" "$bin_real"; then + printf '1bite-battery: ~/.local/bin changed during installation; nothing was published.\n' >&2 + exit 1 + fi + if [[ -n "$target_sha" ]]; then + displaced=$(mktemp "$bin_dir/.1bite-battery.previous.XXXXXX") + rm -f "$displaced" + if [[ -L "$target" || ! -f "$target" || "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$target_sha" ]] || + ! mv -f "$target" "$displaced" || ! directory_matches "$bin_dir" "$bin_identity" "$bin_real" || + [[ -L "$displaced" || ! -f "$displaced" ]] || + [[ "$(sha256_file "$displaced")" != "$target_sha" ]]; then + if [[ -e "$displaced" || -L "$displaced" ]]; then mv -n "$displaced" "$target" || true; fi + rm -f "$temporary" + remove_owned_record "$pending" "$PENDING_FILE_SHA" || true + printf '1bite-battery: installed command changed during publication; it was preserved.\n' >&2 + exit 1 + fi + elif [[ -e "$target" || -L "$target" ]]; then + rm -f "$temporary" + remove_owned_record "$pending" "$PENDING_FILE_SHA" || true + printf '1bite-battery: a new unowned target appeared during installation; it was preserved.\n' >&2 + exit 1 + fi + if ! mv -n "$temporary" "$target"; then + if directory_matches "$bin_dir" "$bin_identity" "$bin_real" && + [[ -n "$displaced" && (-e "$displaced" || -L "$displaced") ]]; then + mv -n "$displaced" "$target" || true + fi + printf '1bite-battery: target publication failed; no ownership receipt was written.\n' >&2 + exit 1 + fi + if ! directory_matches "$bin_dir" "$bin_identity" "$bin_real"; then + printf '1bite-battery: ~/.local/bin changed during publication; no ownership receipt was written.\n' >&2 + exit 1 + fi + if [[ -e "$temporary" || -L "$temporary" ]]; then + rm -f "$temporary" + if [[ -n "$displaced" && (-e "$displaced" || -L "$displaced") ]]; then mv -n "$displaced" "$target" || true; fi + remove_owned_record "$pending" "$PENDING_FILE_SHA" || true + printf '1bite-battery: a target appeared during publication; it was preserved.\n' >&2 + exit 1 + fi + if ! directory_matches "$bin_dir" "$bin_identity" "$bin_real" || + [[ -L "$target" || ! -f "$target" || ! -x "$target" || "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$current_sha" ]] || + ! /bin/bash -n "$target" >/dev/null 2>&1; then + printf '1bite-battery: installed command verification failed; no ownership receipt was written.\n' >&2 + exit 1 + fi + if ! directory_matches "$bin_dir" "$bin_identity" "$bin_real"; then + printf '1bite-battery: ~/.local/bin changed before receipt publication; no ownership receipt was written.\n' >&2 + exit 1 + fi + if ! publish_record "$receipt" $'one-bite-battery-tool\t1' "$BATTERY_TOOL_VERSION" "$current_sha" "$RECEIPT_FILE_SHA"; then + printf '1bite-battery: the receipt changed during publication; the new target remains recoverable from its pending record.\n' >&2 + exit 1 + fi + if ! directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || + ! directory_matches "$bin_dir" "$bin_identity" "$bin_real" || + [[ -L "$target" || ! -f "$target" || ! -x "$target" || "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$current_sha" ]] || + ! /bin/bash -n "$target" >/dev/null 2>&1; then + printf '1bite-battery: managed directories changed after receipt publication; the pending record was retained.\n' >&2 + exit 1 + fi + if ! remove_owned_record "$pending" "$PENDING_FILE_SHA"; then + printf '1bite-battery: the pending record changed after publication; it was preserved for review.\n' >&2 + exit 1 + fi + if ! directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" || + ! directory_matches "$bin_dir" "$bin_identity" "$bin_real"; then + if directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL"; then write_pending "$pending" "$current_sha" || true; fi + printf '1bite-battery: managed directories changed before final verification.\n' >&2 + exit 1 + fi + read_receipt "$receipt" + if [[ "$RECEIPT_VALID" != true || "$RECEIPT_VERSION" != "$BATTERY_TOOL_VERSION" || "$RECEIPT_SHA" != "$current_sha" || + -e "$pending" || -L "$pending" || -L "$target" || ! -f "$target" || ! -x "$target" || + "$(file_link_count "$target")" != 1 || "$(sha256_file "$target")" != "$current_sha" ]] || + ! /bin/bash -n "$target" >/dev/null 2>&1; then + write_pending "$pending" "$current_sha" || true + printf '1bite-battery: final managed-tool verification failed.\n' >&2 + exit 1 + fi + if [[ -n "$displaced" ]] && directory_matches "$bin_dir" "$bin_identity" "$bin_real"; then rm -f "$displaced"; fi + if ! verify_managed_tool_state "$target" "$receipt" "$pending" "$BATTERY_TOOL_VERSION" "$current_sha" "$bin_dir" "$bin_identity" "$bin_real"; then + if directory_matches "$PRIVATE_ROOT" "$PRIVATE_ROOT_IDENTITY" "$PRIVATE_ROOT_REAL" && [[ ! -e "$pending" && ! -L "$pending" ]]; then + write_pending "$pending" "$current_sha" || true + fi + printf '1bite-battery: final managed-tool verification failed; success was not reported.\n' >&2 + exit 1 + fi + printf 'Installed One Bite battery tool: %s\n' "$target" +} + +if [[ "$INSTALL_TOOL" == true ]]; then + install_tool + exit 0 +fi + +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/1bite-battery.XXXXXX") +[[ -d "$WORK_DIR" && ! -L "$WORK_DIR" ]] || { + printf '1bite-battery: could not create a private work directory.\n' >&2 + exit 1 +} +chmod 700 "$WORK_DIR" +trap 'rm -rf "$WORK_DIR"' EXIT HUP INT TERM + +BATTERY_PRESENT= +CHARGE_PERCENT= +POWER_SOURCE=unknown +CHARGE_STATE=unknown +CYCLE_COUNT= +MAX_CAPACITY_PERCENT= +MAX_CAPACITY_MAH= +DESIGN_CAPACITY_MAH= +HEALTH=unknown +VOLTAGE_MV= +CURRENT_MA= +PMSET_AVAILABLE=false +HISTORY_AVAILABLE=false + +collect_pmset_battery() { + local command_status key value + : >"$WORK_DIR/live.tsv" + if [[ ! -x "$PMSET_BIN" ]]; then + add_warning pmset_batt_unavailable + return 0 + fi + set +e + LC_ALL=C "$PMSET_BIN" -g batt 2>/dev/null | awk ' + BEGIN { source="unknown"; present=""; percent=""; state="unknown" } + /Now drawing from/ { + line=tolower($0) + if (index(line, "battery power")) source="battery" + else if (index(line, "ac power")) source="ac" + } + /InternalBattery/ { + present="true" + line=tolower($0) + if (index(line, "present: false")) present="false" + if (match($0, /[0-9][0-9]*[[:space:]]*%/)) { + value=substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", value) + numeric=value+0 + if (numeric >= 0 && numeric <= 100) percent=sprintf("%d", numeric) + } + if (index(line, "; not charging;") || index(line, "; not charging ")) state="not_charging" + else if (index(line, "; discharging;") || index(line, "; discharging ")) state="discharging" + else if (index(line, "; finishing charge;") || index(line, "; finishing charge ")) state="charging" + else if (index(line, "; charging;") || index(line, "; charging ")) state="charging" + else if (index(line, "; charged;") || index(line, "; charged ")) state="charged" + } + END { + if (present == "" && source == "ac") present="false" + print "present\t" present + print "power_source\t" source + print "charge_percent\t" percent + print "charge_state\t" state + } + ' >"$WORK_DIR/live.tsv" + command_status=${PIPESTATUS[0]} + set -e + if [[ $command_status -ne 0 ]]; then + add_warning pmset_batt_unavailable + return 0 + fi + PMSET_AVAILABLE=true + while IFS=$'\t' read -r key value; do + case "$key" in + present) BATTERY_PRESENT=$value ;; + power_source) POWER_SOURCE=$value ;; + charge_percent) CHARGE_PERCENT=$value ;; + charge_state) CHARGE_STATE=$value ;; + esac + done <"$WORK_DIR/live.tsv" + if [[ "$BATTERY_PRESENT" == true && (-z "$CHARGE_PERCENT" || "$POWER_SOURCE" == unknown || "$CHARGE_STATE" == unknown) ]]; then + add_warning pmset_batt_incomplete + elif [[ -z "$BATTERY_PRESENT" || ("$BATTERY_PRESENT" != true && "$POWER_SOURCE" == unknown) ]]; then + add_warning pmset_batt_incomplete + fi +} + +collect_system_profiler() { + local command_status key value normalized + : >"$WORK_DIR/profiler.tsv" + if [[ ! -x "$SYSTEM_PROFILER_BIN" ]]; then + add_warning system_profiler_unavailable + return 0 + fi + set +e + LC_ALL=C "$SYSTEM_PROFILER_BIN" -detailLevel mini -timeout 15 SPPowerDataType 2>/dev/null | awk -F: ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + { + raw=$0 + stripped=raw + sub(/^[[:space:]]+/, "", stripped) + leading=raw + sub(/[^[:space:]].*$/, "", leading) + indent=length(leading) + if (stripped == "Battery Information:") { + in_battery=1 + battery_indent=indent + next + } + if (in_battery && stripped != "" && indent <= battery_indent && stripped ~ /:$/) in_battery=0 + if (!in_battery) next + key=trim($1) + value=substr($0, index($0, ":")+1) + value=trim(value) + if (key == "Cycle Count" && value ~ /^[0-9]+$/) print "cycle_count\t" value + else if (key == "Maximum Capacity" && value ~ /^[0-9]+[[:space:]]*%$/) { + gsub(/[^0-9]/, "", value) + print "maximum_capacity_percent\t" value + } + else if (key == "Condition") { + lower=tolower(value) + if (lower == "normal") normalized="normal" + else if (index(lower, "service recommended")) normalized="service_recommended" + else if (index(lower, "replace soon")) normalized="replace_soon" + else if (index(lower, "replace now")) normalized="replace_now" + else normalized="unknown" + print "health\t" normalized + } + } + ' >"$WORK_DIR/profiler.tsv" + command_status=${PIPESTATUS[0]} + set -e + if [[ $command_status -ne 0 ]]; then + add_warning system_profiler_unavailable + return 0 + fi + while IFS=$'\t' read -r key value; do + case "$key" in + cycle_count) + normalized=$(normalize_unsigned "$value" 100000) || normalized= + if [[ -n "$normalized" ]]; then CYCLE_COUNT=$normalized; fi + ;; + maximum_capacity_percent) + normalized=$(normalize_unsigned "$value" 100) || normalized= + if [[ -n "$normalized" ]]; then MAX_CAPACITY_PERCENT=$normalized; fi + ;; + health) HEALTH=$value ;; + esac + done <"$WORK_DIR/profiler.tsv" + return 0 +} + +collect_ioreg() { + local command_status key value normalized raw_current= + : >"$WORK_DIR/ioreg.tsv" + if [[ ! -x "$IOREG_BIN" ]]; then + add_warning ioreg_unavailable + return 0 + fi + set +e + LC_ALL=C "$IOREG_BIN" -r -c AppleSmartBattery 2>/dev/null | awk ' + { + line=$0 + sub(/^[[:space:]|]+/, "", line) + split(line, halves, " = ") + key=halves[1] + gsub(/^"|"$/, "", key) + value=substr(line, index(line, " = ")+3) + if (key == "CycleCount" || key == "AppleRawMaxCapacity" || key == "DesignCapacity" || + key == "Voltage" || key == "Amperage" || key == "InstantAmperage") { + if (value ~ /^-?[0-9]+$/) print key "\t" value + } else if (key == "BatteryInstalled" && (value == "Yes" || value == "No")) { + print key "\t" value + } + } + ' >"$WORK_DIR/ioreg.tsv" + command_status=${PIPESTATUS[0]} + set -e + if [[ $command_status -ne 0 ]]; then + add_warning ioreg_unavailable + return 0 + fi + while IFS=$'\t' read -r key value; do + case "$key" in + CycleCount) + if [[ -z "$CYCLE_COUNT" ]]; then + normalized=$(normalize_unsigned "$value" 100000) || normalized= + if [[ -n "$normalized" ]]; then CYCLE_COUNT=$normalized; fi + fi + ;; + AppleRawMaxCapacity) + normalized=$(normalize_unsigned "$value" 100000) || normalized= + if [[ -n "$normalized" ]]; then MAX_CAPACITY_MAH=$normalized; fi + ;; + DesignCapacity) + normalized=$(normalize_unsigned "$value" 100000) || normalized= + if [[ -n "$normalized" ]]; then DESIGN_CAPACITY_MAH=$normalized; fi + ;; + Voltage) + normalized=$(normalize_unsigned "$value" 100000) || normalized= + if [[ -n "$normalized" ]]; then VOLTAGE_MV=$normalized; fi + ;; + InstantAmperage) raw_current=$value ;; + Amperage) [[ -n "$raw_current" ]] || raw_current=$value ;; + BatteryInstalled) + if [[ "$PMSET_AVAILABLE" != true ]]; then + if [[ "$value" == Yes ]]; then BATTERY_PRESENT=true; else BATTERY_PRESENT=false; fi + fi + ;; + esac + done <"$WORK_DIR/ioreg.tsv" + if [[ -n "$raw_current" ]]; then + normalized=$(normalize_current_ma "$raw_current") || normalized= + if [[ -n "$normalized" ]]; then CURRENT_MA=$normalized; fi + fi + return 0 +} + +collect_pmset_battery +if [[ "$BATTERY_PRESENT" == true ]]; then + collect_system_profiler + collect_ioreg +fi + +if ! read -r COLLECTED_EPOCH COLLECTED_AT < <("$DATE_BIN" -u '+%s %Y-%m-%dT%H:%M:%SZ'); then + printf '1bite-battery: system date could not provide a collection time.\n' >&2 + exit 1 +fi +COLLECTED_EPOCH=$(normalize_unsigned "$COLLECTED_EPOCH" 99999999999) || { + printf '1bite-battery: system date returned an invalid epoch.\n' >&2 + exit 1 +} +COLLECTED_AT_EPOCH=$(utc_iso_epoch "$COLLECTED_AT") || { + printf '1bite-battery: system date returned an invalid UTC timestamp.\n' >&2 + exit 1 +} +if [[ "$COLLECTED_AT_EPOCH" != "$COLLECTED_EPOCH" ]]; then + printf '1bite-battery: system date returned inconsistent timestamp values.\n' >&2 + exit 1 +fi + +CACHE_READABLE=false +CACHE_WRITABLE=false +CACHE_FILE= +PRIVATE_DIR_READY=false + +cache_valid() { + awk -F '\t' -v max_events="$MAX_RETAINED_EVENTS" ' + function uint(value) { return value ~ /^(0|[1-9][0-9]*)$/ } + function sint(value) { return value ~ /^(0|[1-9][0-9]*|-[1-9][0-9]*)$/ } + function leap(year) { return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } + function dim(year, month) { + if (month == 2) return 28 + leap(year) + if (month == 4 || month == 6 || month == 9 || month == 11) return 30 + return 31 + } + function iso_epoch(value, year, month, day, hour, minute, second, zone_hour, zone_minute, sign, days, cursor, offset) { + if (length(value) != 20 && length(value) != 25) return -1 + if (substr(value,5,1) != "-" || substr(value,8,1) != "-" || substr(value,11,1) != "T" || + substr(value,14,1) != ":" || substr(value,17,1) != ":") return -1 + if (substr(value,1,4) !~ /^[0-9][0-9][0-9][0-9]$/ || + substr(value,6,2) !~ /^[0-9][0-9]$/ || substr(value,9,2) !~ /^[0-9][0-9]$/ || + substr(value,12,2) !~ /^[0-9][0-9]$/ || substr(value,15,2) !~ /^[0-9][0-9]$/ || + substr(value,18,2) !~ /^[0-9][0-9]$/) return -1 + year=substr(value,1,4)+0; month=substr(value,6,2)+0; day=substr(value,9,2)+0 + hour=substr(value,12,2)+0; minute=substr(value,15,2)+0; second=substr(value,18,2)+0 + if (year < 1970 || month < 1 || month > 12 || day < 1 || day > dim(year,month) || + hour > 23 || minute > 59 || second > 59) return -1 + offset=0 + if (length(value) == 20) { + if (substr(value,20,1) != "Z") return -1 + } else { + if ((substr(value,20,1) != "+" && substr(value,20,1) != "-") || substr(value,23,1) != ":" || + substr(value,21,2) !~ /^[0-9][0-9]$/ || substr(value,24,2) !~ /^[0-9][0-9]$/) return -1 + zone_hour=substr(value,21,2)+0; zone_minute=substr(value,24,2)+0 + if (zone_hour > 14 || zone_minute > 59 || (zone_hour == 14 && zone_minute != 0)) return -1 + sign=(substr(value,20,1) == "-") ? -1 : 1 + offset=(zone_hour*60+zone_minute)*60*sign + } + days=0 + for (cursor=1970; cursor 100 || !uint($6) || $6+0 > 999999 || + iso_epoch($3) != $2+0 || event_count >= max_events) exit 1 + if (event_count > 0 && ($2+0 < previous_epoch || ($2+0 == previous_epoch && $6+0 < previous_ordinal) || + $4 == previous_source)) exit 1 + previous_epoch=$2+0; previous_ordinal=$6+0; previous_source=$4 + event_count++ + next + } + $1 == "interval" { + if (NF != 12 || !uint($2) || $3 !~ /^(ac|battery)$/ || + !uint($4) || !uint($6) || $6+0 > 100 || + !uint($7) || !uint($9) || $9+0 > 100 || + !sint($10) || !uint($11) || $12 !~ /^(true|false)$/ || + $2+0 != interval_count || iso_epoch($5) != $4+0 || iso_epoch($8) != $7+0 || + $7+0 < $4+0 || $10+0 != $9+0-$6+0 || $11+0 != $7+0-$4+0 || seen_open) exit 1 + if ($12 == "false") seen_open=1 + interval_count++ + next + } + { exit 1 } + END { + if (NR < 1 || (interval_count != event_count && interval_count != event_count-1)) exit 1 + } + ' "$1" >/dev/null +} + +prepare_cache() { + local first_line= + [[ "$CACHE_ENABLED" == true && "$BATTERY_PRESENT" == true ]] || return 0 + ensure_private_battery_dir + if [[ "$PRIVATE_DIR_READY" != true ]]; then return 0; fi + CACHE_FILE="$PRIVATE_ROOT/history-v1.tsv" + if [[ -L "$CACHE_FILE" ]]; then + add_warning cache_symlink + return 0 + fi + CACHE_WRITABLE=true + if [[ ! -e "$CACHE_FILE" ]]; then return 0; fi + if [[ ! -f "$CACHE_FILE" ]]; then + add_warning cache_unsafe + CACHE_WRITABLE=false + return 0 + fi + IFS= read -r first_line <"$CACHE_FILE" || true + if [[ "$first_line" != $'one-bite-battery-history\t1' ]]; then + add_warning cache_unknown + CACHE_WRITABLE=false + return 0 + fi + set +e + cache_valid "$CACHE_FILE" + local valid_status=$? + set -e + if [[ $valid_status -eq 0 ]]; then + CACHE_READABLE=true + else + add_warning cache_corrupt + fi +} + +parse_history_stream() { + awk ' + function leap(year) { return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } + function dim(year, month) { + if (month == 2) return 28 + leap(year) + if (month == 4 || month == 6 || month == 9 || month == 11) return 30 + return 31 + } + function epoch_for(stamp, zone, values, date_values, time_values, year, month, day, hour, minute, second, zone_hour, zone_minute, days, cursor, offset, sign) { + split(stamp, values, " ") + split(values[1], date_values, "-") + split(values[2], time_values, ":") + year=date_values[1]+0; month=date_values[2]+0; day=date_values[3]+0 + hour=time_values[1]+0; minute=time_values[2]+0; second=time_values[3]+0 + if (year < 1970 || month < 1 || month > 12 || day < 1 || day > dim(year, month) || + hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) return -1 + zone_hour=substr(zone,2,2)+0; zone_minute=substr(zone,4,2)+0 + if (zone_hour > 14 || zone_minute > 59 || (zone_hour == 14 && zone_minute != 0)) return -1 + days=0 + for (cursor=1970; cursor 100) next + epoch=epoch_for(stamp, zone) + if (epoch < 0) next + if (epoch == previous_epoch) ordinal++ + else { previous_epoch=epoch; ordinal=0 } + iso=substr(stamp,1,10) "T" substr(stamp,12) substr(zone,1,3) ":" substr(zone,4,2) + printf "event\t%.0f\t%s\t%s\t%d\t%d\n", epoch, iso, source, percent, ordinal + parsed++ + } + END { + if (candidates > parsed && parsed > 0) exit 43 + if (NR > 0 && parsed == 0) exit 42 + } + ' +} + +collect_history() { + local -a pipeline_status + : >"$WORK_DIR/fresh-events.tsv" + if [[ ! -x "$PMSET_BIN" ]]; then + add_warning history_unavailable + return 0 + fi + set +e + LC_ALL=C "$PMSET_BIN" -g log 2>/dev/null | parse_history_stream >"$WORK_DIR/fresh-events.tsv" + pipeline_status=("${PIPESTATUS[@]}") + set -e + if [[ ${pipeline_status[0]} -ne 0 ]]; then + add_warning history_unavailable + : >"$WORK_DIR/fresh-events.tsv" + return 0 + fi + if [[ ${pipeline_status[1]:-1} -ne 0 ]]; then + if [[ ${pipeline_status[1]} -eq 43 ]]; then + add_warning history_parse_partial + else + add_warning history_parse_failed + fi + : >"$WORK_DIR/fresh-events.tsv" + return 0 + fi + HISTORY_AVAILABLE=true +} + +if [[ "$OUTPUT_FORMAT" != summary ]]; then prepare_cache; fi +: >"$WORK_DIR/combined-events.tsv" +if [[ "$BATTERY_PRESENT" == true && "$OUTPUT_FORMAT" != summary ]]; then + if [[ "$CACHE_READABLE" == true ]]; then + awk -F '\t' '$1 == "event" { print }' "$CACHE_FILE" >>"$WORK_DIR/combined-events.tsv" + fi + collect_history + cat "$WORK_DIR/fresh-events.tsv" >>"$WORK_DIR/combined-events.tsv" + if [[ -n "$CHARGE_PERCENT" && ("$POWER_SOURCE" == ac || "$POWER_SOURCE" == battery) ]]; then + printf 'event\t%s\t%s\t%s\t%s\t999999\n' "$COLLECTED_EPOCH" "$COLLECTED_AT" "$POWER_SOURCE" "$CHARGE_PERCENT" >>"$WORK_DIR/combined-events.tsv" + fi +fi + +TAB=$'\t' +LC_ALL=C sort -t "$TAB" -k2,2n -k6,6n -k3,3 -k4,4 "$WORK_DIR/combined-events.tsv" | awk -F '\t' -v now="$COLLECTED_EPOCH" ' + $1 == "event" && $2+0 <= now+0 { + key=$2 FS $3 FS $4 FS $5 FS $6 + if (seen[key]++) next + if ($4 != previous_source) { + print + previous_source=$4 + } + } +' | tail -n "$MAX_RETAINED_EVENTS" >"$WORK_DIR/events.tsv" + +awk -F '\t' -v end_epoch="$COLLECTED_EPOCH" -v end_iso="$COLLECTED_AT" -v end_percent="${CHARGE_PERCENT:-}" ' + function emit_interval(source, start_epoch, start_iso, start_percent, finish_epoch, finish_iso, finish_percent, complete, duration, delta) { + duration=finish_epoch-start_epoch + if (duration < 0) return + delta=finish_percent-start_percent + printf "interval\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\t%s\n", count++, source, + start_epoch, start_iso, start_percent, finish_epoch, finish_iso, finish_percent, delta, duration, complete + } + NR == 1 { + previous_source=$4; previous_epoch=$2; previous_iso=$3; previous_percent=$5 + next + } + { + emit_interval(previous_source, previous_epoch, previous_iso, previous_percent, $2, $3, $5, "true") + previous_source=$4; previous_epoch=$2; previous_iso=$3; previous_percent=$5 + } + END { + if (NR > 0 && end_percent ~ /^[0-9]+$/ && end_epoch+0 >= previous_epoch+0) + emit_interval(previous_source, previous_epoch, previous_iso, previous_percent, end_epoch, end_iso, end_percent, "false") + } +' "$WORK_DIR/events.tsv" >"$WORK_DIR/intervals.tsv" + +RETAINED_EVENT_COUNT=$(awk 'END { print NR+0 }' "$WORK_DIR/events.tsv") +INTERVAL_COUNT=$(awk 'END { print NR+0 }' "$WORK_DIR/intervals.tsv") + +write_cache() { + local temporary valid_status + [[ "$CACHE_WRITABLE" == true ]] || return 0 + if path_has_symlink "$PRIVATE_ROOT" || [[ -L "$CACHE_FILE" ]]; then + add_warning cache_symlink + return 0 + fi + if ! temporary=$(mktemp "$PRIVATE_ROOT/.history-v1.tsv.XXXXXX" 2>/dev/null); then + add_warning cache_write_failed + return 0 + fi + if ! printf 'one-bite-battery-history\t1\n' >"$temporary" || + ! cat "$WORK_DIR/events.tsv" >>"$temporary" || + ! cat "$WORK_DIR/intervals.tsv" >>"$temporary" || + ! chmod 600 "$temporary"; then + rm -f "$temporary" + add_warning cache_write_failed + return 0 + fi + set +e + cache_valid "$temporary" + valid_status=$? + set -e + if [[ $valid_status -ne 0 ]]; then + rm -f "$temporary" + add_warning cache_write_invalid + return 0 + fi + if path_has_symlink "$PRIVATE_ROOT" || [[ -L "$CACHE_FILE" ]]; then + rm -f "$temporary" + add_warning cache_symlink + return 0 + fi + if ! mv -f "$temporary" "$CACHE_FILE"; then + rm -f "$temporary" + add_warning cache_write_failed + fi +} + +if [[ "$BATTERY_PRESENT" == true ]]; then write_cache; fi + +if [[ "$PMSET_AVAILABLE" != true || -z "$BATTERY_PRESENT" || ("$BATTERY_PRESENT" != true && "$POWER_SOURCE" == unknown) ]]; then + REPORT_STATUS=unavailable +elif [[ "$BATTERY_PRESENT" != true ]]; then + REPORT_STATUS=no_battery +elif [[ -z "$CHARGE_PERCENT" || "$POWER_SOURCE" == unknown || "$CHARGE_STATE" == unknown || -z "$CYCLE_COUNT" || -z "$MAX_CAPACITY_PERCENT" || -z "$MAX_CAPACITY_MAH" || -z "$DESIGN_CAPACITY_MAH" || "$HEALTH" == unknown || -z "$VOLTAGE_MV" || -z "$CURRENT_MA" || "$HISTORY_AVAILABLE" != true ]]; then + REPORT_STATUS=partial + add_warning metrics_incomplete +else + REPORT_STATUS=ok +fi +if [[ "$REPORT_STATUS" == ok || "$REPORT_STATUS" == no_battery ]]; then REPORT_COMPLETE=true; else REPORT_COMPLETE=false; fi + +json_number() { + if [[ -n "$1" ]]; then printf '%s' "$1"; else printf 'null'; fi +} + +json_boolean_or_null() { + if [[ -n "$1" ]]; then printf '%s' "$1"; else printf 'null'; fi +} + +json_quote() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + printf '"%s"' "$value" +} + +json_string_or_null() { + if [[ -n "$1" ]]; then json_quote "$1"; else printf 'null'; fi +} + +print_warning_json() { + local warning first=true + printf '[' + for warning in $WARNINGS; do + if [[ "$first" == true ]]; then first=false; else printf ','; fi + json_quote "$warning" + done + printf ']' +} + +print_intervals_json() { + local record index source start_at start_percent end_at end_percent delta duration complete first=true + printf '[' + while IFS=$'\t' read -r record index source _ start_at start_percent _ end_at end_percent delta duration complete; do + [[ "$record" == interval ]] || continue + if [[ "$first" == true ]]; then first=false; else printf ','; fi + printf '{"source":' + json_quote "$source" + printf ',"start_at":' + json_quote "$start_at" + printf ',"end_at":' + json_quote "$end_at" + printf ',"start_percent":%s,"end_percent":%s,"delta_percent":%s,"duration_seconds":%s,"complete":%s}' \ + "$start_percent" "$end_percent" "$delta" "$duration" "$complete" + done <"$WORK_DIR/intervals.tsv" + printf ']' +} + +print_json() { + printf '{' + printf '"schema_version":%s,' "$BATTERY_SCHEMA_VERSION" + printf '"collected_at":' + json_quote "$COLLECTED_AT" + printf ',"status":' + json_quote "$REPORT_STATUS" + printf ',' + printf '"complete":%s,' "$REPORT_COMPLETE" + printf '"battery":{' + printf '"present":' + json_boolean_or_null "$BATTERY_PRESENT" + printf ',' + printf '"charge_percent":' + json_number "$CHARGE_PERCENT" + printf ',' + printf '"power_source":' + json_string_or_null "$POWER_SOURCE" + printf ',' + printf '"state":' + json_string_or_null "$CHARGE_STATE" + printf ',' + printf '"cycle_count":' + json_number "$CYCLE_COUNT" + printf ',' + printf '"maximum_capacity_percent":' + json_number "$MAX_CAPACITY_PERCENT" + printf ',' + printf '"maximum_capacity_mah":' + json_number "$MAX_CAPACITY_MAH" + printf ',' + printf '"design_capacity_mah":' + json_number "$DESIGN_CAPACITY_MAH" + printf ',' + printf '"health":' + json_quote "$HEALTH" + printf ',' + printf '"voltage_mv":' + json_number "$VOLTAGE_MV" + printf ',' + printf '"current_ma":' + json_number "$CURRENT_MA" + printf '},' + printf '"history":{"duration_basis":"wall_clock","may_include_sleep":true,"is_lifetime":false,"retained_event_count":%s,"interval_count":%s,"intervals":' "$RETAINED_EVENT_COUNT" "$INTERVAL_COUNT" + print_intervals_json + printf '},"warning_codes":' + print_warning_json + printf '}\n' +} + +tsv_value() { + if [[ -n "$1" ]]; then printf '%s' "$1"; else printf 'null'; fi +} + +print_tsv_row() { + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$BATTERY_SCHEMA_VERSION" "$1" "$2" "$3" "$4" "$5" +} + +print_tsv() { + local record index source start_at start_percent end_at end_percent delta duration complete warning warning_index=0 + printf 'schema_version\tsection\tindex\tkey\tvalue\tunit\n' + print_tsv_row meta - collected_at "$COLLECTED_AT" iso8601 + print_tsv_row meta - status "$REPORT_STATUS" enum + print_tsv_row meta - complete "$REPORT_COMPLETE" boolean + print_tsv_row battery - present "$(tsv_value "$BATTERY_PRESENT")" boolean + print_tsv_row battery - charge_percent "$(tsv_value "$CHARGE_PERCENT")" percent + print_tsv_row battery - power_source "$POWER_SOURCE" enum + print_tsv_row battery - state "$CHARGE_STATE" enum + print_tsv_row battery - cycle_count "$(tsv_value "$CYCLE_COUNT")" equivalent_full_cycles + print_tsv_row battery - maximum_capacity_percent "$(tsv_value "$MAX_CAPACITY_PERCENT")" percent + print_tsv_row battery - maximum_capacity_mah "$(tsv_value "$MAX_CAPACITY_MAH")" mAh + print_tsv_row battery - design_capacity_mah "$(tsv_value "$DESIGN_CAPACITY_MAH")" mAh + print_tsv_row battery - health "$HEALTH" enum + print_tsv_row battery - voltage_mv "$(tsv_value "$VOLTAGE_MV")" mV + print_tsv_row battery - current_ma "$(tsv_value "$CURRENT_MA")" mA + print_tsv_row history - duration_basis wall_clock enum + print_tsv_row history - may_include_sleep true boolean + print_tsv_row history - is_lifetime false boolean + print_tsv_row history - retained_event_count "$RETAINED_EVENT_COUNT" count + print_tsv_row history - interval_count "$INTERVAL_COUNT" count + while IFS=$'\t' read -r record index source _ start_at start_percent _ end_at end_percent delta duration complete; do + [[ "$record" == interval ]] || continue + print_tsv_row interval "$index" source "$source" enum + print_tsv_row interval "$index" start_at "$start_at" iso8601 + print_tsv_row interval "$index" end_at "$end_at" iso8601 + print_tsv_row interval "$index" start_percent "$start_percent" percent + print_tsv_row interval "$index" end_percent "$end_percent" percent + print_tsv_row interval "$index" delta_percent "$delta" percentage_points + print_tsv_row interval "$index" duration_seconds "$duration" seconds + print_tsv_row interval "$index" complete "$complete" boolean + done <"$WORK_DIR/intervals.tsv" + for warning in $WARNINGS; do + print_tsv_row warning "$warning_index" code "$warning" enum + warning_index=$((warning_index + 1)) + done +} + +shell_assignment() { + local name=$1 value=$2 + value=${value//\'/\'"\'"\'} + printf "%s='%s'\n" "$name" "$value" +} + +print_shell() { + local record index source start_at start_percent end_at end_percent delta duration complete prefix + cat <<'SHELL_CLEANUP' +case ${ONE_BITE_BATTERY_INTERVAL_COUNT-} in + [0-9]|[0-9][0-9]|[0-9][0-9][0-9]) one_bite_battery_previous_intervals=$((10#$ONE_BITE_BATTERY_INTERVAL_COUNT)) ;; + *) one_bite_battery_previous_intervals=0 ;; +esac +if ((one_bite_battery_previous_intervals > 256)); then one_bite_battery_previous_intervals=256; fi +one_bite_battery_interval_index=0 +while ((one_bite_battery_interval_index < one_bite_battery_previous_intervals)); do + for one_bite_battery_interval_suffix in SOURCE START_AT END_AT START_PERCENT END_PERCENT DELTA_PERCENT DURATION_SECONDS COMPLETE; do + unset "ONE_BITE_BATTERY_INTERVAL_${one_bite_battery_interval_index}_${one_bite_battery_interval_suffix}" + done + one_bite_battery_interval_index=$((one_bite_battery_interval_index + 1)) +done +unset one_bite_battery_previous_intervals one_bite_battery_interval_index one_bite_battery_interval_suffix +SHELL_CLEANUP + shell_assignment ONE_BITE_BATTERY_SCHEMA_VERSION "$BATTERY_SCHEMA_VERSION" + shell_assignment ONE_BITE_BATTERY_COLLECTED_AT "$COLLECTED_AT" + shell_assignment ONE_BITE_BATTERY_STATUS "$REPORT_STATUS" + shell_assignment ONE_BITE_BATTERY_COMPLETE "$REPORT_COMPLETE" + shell_assignment ONE_BITE_BATTERY_PRESENT "$BATTERY_PRESENT" + shell_assignment ONE_BITE_BATTERY_CHARGE_PERCENT "$CHARGE_PERCENT" + shell_assignment ONE_BITE_BATTERY_POWER_SOURCE "$POWER_SOURCE" + shell_assignment ONE_BITE_BATTERY_STATE "$CHARGE_STATE" + shell_assignment ONE_BITE_BATTERY_CYCLE_COUNT "$CYCLE_COUNT" + shell_assignment ONE_BITE_BATTERY_MAXIMUM_CAPACITY_PERCENT "$MAX_CAPACITY_PERCENT" + shell_assignment ONE_BITE_BATTERY_MAXIMUM_CAPACITY_MAH "$MAX_CAPACITY_MAH" + shell_assignment ONE_BITE_BATTERY_DESIGN_CAPACITY_MAH "$DESIGN_CAPACITY_MAH" + shell_assignment ONE_BITE_BATTERY_HEALTH "$HEALTH" + shell_assignment ONE_BITE_BATTERY_VOLTAGE_MV "$VOLTAGE_MV" + shell_assignment ONE_BITE_BATTERY_CURRENT_MA "$CURRENT_MA" + shell_assignment ONE_BITE_BATTERY_DURATION_BASIS wall_clock + shell_assignment ONE_BITE_BATTERY_MAY_INCLUDE_SLEEP true + shell_assignment ONE_BITE_BATTERY_IS_LIFETIME false + shell_assignment ONE_BITE_BATTERY_RETAINED_EVENT_COUNT "$RETAINED_EVENT_COUNT" + shell_assignment ONE_BITE_BATTERY_INTERVAL_COUNT "$INTERVAL_COUNT" + shell_assignment ONE_BITE_BATTERY_WARNING_CODES "$WARNINGS" + while IFS=$'\t' read -r record index source _ start_at start_percent _ end_at end_percent delta duration complete; do + [[ "$record" == interval ]] || continue + prefix="ONE_BITE_BATTERY_INTERVAL_${index}" + shell_assignment "${prefix}_SOURCE" "$source" + shell_assignment "${prefix}_START_AT" "$start_at" + shell_assignment "${prefix}_END_AT" "$end_at" + shell_assignment "${prefix}_START_PERCENT" "$start_percent" + shell_assignment "${prefix}_END_PERCENT" "$end_percent" + shell_assignment "${prefix}_DELTA_PERCENT" "$delta" + shell_assignment "${prefix}_DURATION_SECONDS" "$duration" + shell_assignment "${prefix}_COMPLETE" "$complete" + done <"$WORK_DIR/intervals.tsv" +} + +field_value() { + case "$FIELD_NAME" in + schema_version) printf '%s' "$BATTERY_SCHEMA_VERSION" ;; + collected_at) printf '%s' "$COLLECTED_AT" ;; + status) printf '%s' "$REPORT_STATUS" ;; + complete) printf '%s' "$REPORT_COMPLETE" ;; + battery.present) printf '%s' "$BATTERY_PRESENT" ;; + battery.charge_percent) printf '%s' "$CHARGE_PERCENT" ;; + battery.power_source) printf '%s' "$POWER_SOURCE" ;; + battery.state) printf '%s' "$CHARGE_STATE" ;; + battery.cycle_count) printf '%s' "$CYCLE_COUNT" ;; + battery.maximum_capacity_percent) printf '%s' "$MAX_CAPACITY_PERCENT" ;; + battery.maximum_capacity_mah) printf '%s' "$MAX_CAPACITY_MAH" ;; + battery.design_capacity_mah) printf '%s' "$DESIGN_CAPACITY_MAH" ;; + battery.health) printf '%s' "$HEALTH" ;; + battery.voltage_mv) printf '%s' "$VOLTAGE_MV" ;; + battery.current_ma) printf '%s' "$CURRENT_MA" ;; + history.retained_event_count) printf '%s' "$RETAINED_EVENT_COUNT" ;; + history.interval_count) printf '%s' "$INTERVAL_COUNT" ;; + *) + printf '1bite-battery: unknown field: %s\n' "$FIELD_NAME" >&2 + exit 2 + ;; + esac +} + +print_field() { + local value + value=$(field_value) + if [[ -z "$value" ]]; then exit 3; fi + printf '%s\n' "$value" +} + +human_value() { + if [[ -n "$1" ]]; then printf '%s' "$1"; else printf 'unavailable'; fi +} + +format_duration() { + local total=$1 days hours minutes + days=$((total / 86400)) + hours=$(((total % 86400) / 3600)) + minutes=$(((total % 3600) / 60)) + if [[ $days -gt 0 ]]; then + printf '%dd %dh %dm' "$days" "$hours" "$minutes" + elif [[ $hours -gt 0 ]]; then + printf '%dh %dm' "$hours" "$minutes" + else + printf '%dm' "$minutes" + fi +} + +print_dashboard() { + local record index source start_at start_percent end_at end_percent delta duration complete shown=0 + printf 'One Bite Battery\n' + printf '================\n' + printf 'Status: %s\n' "$REPORT_STATUS" + if [[ "$REPORT_STATUS" == unavailable ]]; then + printf 'Battery data: unavailable\n' + elif [[ "$BATTERY_PRESENT" != true ]]; then + printf 'Battery: not present\n' + else + printf 'Charge: %s%% (%s, %s)\n' "$(human_value "$CHARGE_PERCENT")" "$POWER_SOURCE" "$CHARGE_STATE" + printf 'Health: %s; maximum capacity %s%% (%s mAh of %s mAh design)\n' "$HEALTH" \ + "$(human_value "$MAX_CAPACITY_PERCENT")" "$(human_value "$MAX_CAPACITY_MAH")" "$(human_value "$DESIGN_CAPACITY_MAH")" + printf 'Cycle count: %s equivalent full cycles\n' "$(human_value "$CYCLE_COUNT")" + printf 'Electrical: %s mV, %s mA\n' "$(human_value "$VOLTAGE_MV")" "$(human_value "$CURRENT_MA")" + printf '\nRecent power intervals (%s retained):\n' "$INTERVAL_COUNT" + while IFS=$'\t' read -r record index source _ start_at start_percent _ end_at end_percent delta duration complete; do + [[ "$record" == interval ]] || continue + if [[ $((INTERVAL_COUNT - index)) -le 8 ]]; then + printf ' %s %3s%% -> %3s%% %8s %s -> %s%s\n' "$source" "$start_percent" "$end_percent" \ + "$(format_duration "$duration")" "$start_at" "$end_at" "$([[ "$complete" == false ]] && printf ' (open)')" + shown=$((shown + 1)) + fi + done <"$WORK_DIR/intervals.tsv" + [[ $shown -gt 0 ]] || printf ' No usable transition history was found.\n' + fi + printf '\nNotes:\n' + printf ' - Cycle count means equivalent full cycles, not the number of times power was connected.\n' + printf ' - Durations are wall-clock time and may include sleep.\n' + printf ' - The listed interval count covers retained recent observations, not lifetime charge/discharge totals.\n' + printf ' - Only allowlisted battery fields are shown; device and account identifiers are never retained.\n' + if [[ -n "$WARNINGS" ]]; then printf 'Warnings: %s\n' "$WARNINGS"; fi +} + +print_summary() { + local charge='unavailable' source='power source unavailable' state='state unavailable' + local maximum='unavailable' cycles='unavailable' + if [[ "$REPORT_STATUS" == unavailable ]]; then + printf 'Battery: unavailable; setup will continue.\n' + return + fi + if [[ "$BATTERY_PRESENT" != true ]]; then + printf 'Battery: not present.\n' + return + fi + if [[ -n "$CHARGE_PERCENT" ]]; then charge="$CHARGE_PERCENT%"; fi + case "$POWER_SOURCE" in + ac) source='AC power' ;; + battery) source='battery power' ;; + esac + case "$CHARGE_STATE" in + charging) state=charging ;; + charged) state=charged ;; + discharging) state=discharging ;; + not_charging) state='not charging' ;; + esac + if [[ -n "$MAX_CAPACITY_PERCENT" ]]; then maximum="$MAX_CAPACITY_PERCENT%"; fi + if [[ -n "$CYCLE_COUNT" ]]; then cycles="$CYCLE_COUNT equivalent full cycles"; fi + printf 'Battery: charge %s; %s, %s; maximum capacity %s; cycle count %s.\n' \ + "$charge" "$source" "$state" "$maximum" "$cycles" +} + +case "$OUTPUT_FORMAT" in + dashboard) print_dashboard ;; + summary) print_summary ;; + json) print_json ;; + tsv) print_tsv ;; + shell) print_shell ;; + field) print_field ;; +esac + +if [[ "$REPORT_STATUS" == unavailable ]]; then exit 1; fi diff --git a/scripts/verify.sh b/scripts/verify.sh index c617d1e..420c71c 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -60,7 +60,6 @@ else fi if [[ "$DESKTOP_MODE" == managed ]]; then python3 "$ROOT/scripts/kiro-shell.py" --verify --app "$(app_path 'Kiro CLI.app')" - zsh -lic '(( $+functions[fig_preexec] && $+functions[fig_precmd] ))' fi if [[ "$WITH_DOCKER" == true ]] && app_healthy Docker.app; then docker --version diff --git a/tests/fixtures/battery/pmset-log-batt-uppercase.txt b/tests/fixtures/battery/pmset-log-batt-uppercase.txt new file mode 100644 index 0000000..3ed5b75 --- /dev/null +++ b/tests/fixtures/battery/pmset-log-batt-uppercase.txt @@ -0,0 +1,4 @@ +2026-09-01 01:00:00 -0700 Assertions Summary Using AC(Charge :5) +2026-09-01 01:15:00 -0700 Wake Using BATT (Charge : 005 %) +2026-09-01 01:45:00 -0700 DarkWake Using BATT (Charge: 04%) 12 secs +2026-09-01 02:00:00 -0700 Assertions Summary Using AC(Charge: 4 %) diff --git a/tests/fixtures/battery/pmset-log-batt.txt b/tests/fixtures/battery/pmset-log-batt.txt new file mode 100644 index 0000000..b16a863 --- /dev/null +++ b/tests/fixtures/battery/pmset-log-batt.txt @@ -0,0 +1,9 @@ +2026-09-01 08:00:00 +0000 Wake Wake from Deep Idle Using Batt (Charge: 09%) +2026-09-01 08:10:00 +0000 DarkWake Maintenance Using Batt (Charge: 08%) 45 secs +2026-09-01 08:30:00 +0000 Assertions Summary Using AC (Charge: 08%) +2026-09-01 09:00:00 +0000 Assertions Summary Using AC (Charge: 40%) +2026-09-01 09:00:00 +0000 Wake Lid Open Using Batt (Charge: 40%) +2026-09-01 09:00:00 +0000 Assertions Summary Using AC (Charge: 40%) +2026-09-01 09:30:00 +0000 Sleep Entering Sleep Using AC (Charge: 70%) +2026-09-01 10:00:00 +0000 Wake Wake from Sleep Using Batt (Charge: 100%) +2026-09-01 12:00:00 +0000 DarkWake SleepService Using Batt (Charge: 70%) 20 secs diff --git a/tests/fixtures/config-golden.json b/tests/fixtures/config-golden.json index a720b0c..3a2dbb2 100644 --- a/tests/fixtures/config-golden.json +++ b/tests/fixtures/config-golden.json @@ -22,7 +22,7 @@ "obsidian-vault/.obsidian/themes/Tokyo Night/theme.css": "/*Fonts*/\n:root {\n --default-font: '\"JetBrains Mono\", monospace, \"Inter\", sans-serif';\n --font-monospace: '\"JetBrains Mono\", monospace, \"Source Code Pro\", monospace';\n}\n\n.theme-dark {\n color-scheme: dark;\n /* --accent-h: 261; /* magenta */\n /* --accent-s: 85%; */\n /* --accent-l: 79%; */\n /* cyan */\n --accent-h: 202;\n --accent-s: 100%;\n --accent-l: 75%;\n\n --bg_dark2_x: 18, 18, 24;\n --bg_dark2: rgb(var(--bg_dark2_x));\n --bg_dark_x: 22, 22, 30;\n --bg_dark: rgb(var(--bg_dark_x));\n --bg_x: 26, 27, 38;\n --bg: rgb(var(--bg_x));\n --bg_highlight_x: 41, 46, 66;\n --bg_highlight: rgb(var(--bg_highlight_x));\n --bg_highlight_dark_x: 36, 40, 59;\n --bg_highlight_dark: rgb(var(--bg_highlight_dark_x));\n --terminal_black_x: 65, 72, 104;\n --terminal_black: rgb(var(--terminal_black_x));\n --fg_x: 192, 202, 245;\n --fg: rgb(var(--fg_x));\n --fg_dark_x: 169, 177, 214;\n --fg_dark: rgb(var(--fg_dark_x));\n --comment_x: 86, 95, 137;\n --comment: rgb(var(--comment_x));\n --blue0_x: 61, 89, 161;\n --blue0: rgb(var(--blue0_x));\n --blue_x: 122, 162, 247;\n --blue: rgb(var(--blue_x));\n --cyan_hsl: 202 100% 75%;\n --cyan_x: 125, 207, 255;\n --cyan: rgb(var(--cyan_x));\n --magent_hsl: 261 85% 79%;\n --magenta_x: 187, 154, 247;\n --magenta: rgb(var(--magenta_x));\n --pink_hsl: 331 100% 43%;\n --pink_x: rgb(217, 0, 105);\n --pink: var(--pink_x);\n --orange_x: 255, 158, 100;\n --orange: rgb(var(--orange_x));\n --yellow_x: 224, 175, 104;\n --yellow: rgb(var(--yellow_x));\n --green_x: 158, 206, 106;\n --green: rgb(var(--green_x));\n --teal_x: 26, 188, 156;\n --teal: rgb(var(--teal_x));\n --red_x: 255, 117, 127;\n --red: rgb(var(--red_x));\n --red1_x: 219, 75, 75;\n --red1: rgb(var(--red1_x));\n --unknown: #ffffff;\n\n --link-external-filter: invert(35%) sepia(28%) saturate(681%) hue-rotate(192deg) brightness(94%) contrast(85%);\n}\n\n.theme-light {\n /* --accent-h: 261; /* magenta */\n /* --accent-s: 24%; */\n /* --accent-l: 38%; */\n /* cyan */\n --accent-h: 202;\n --accent-s: 86%;\n --accent-l: 43%;\n\n --bg_dark2_x: 188, 189, 194;\n --bg_dark2: rgb(var(--bg_dark2_x));\n --bg_dark_x: 203, 204, 209;\n --bg_dark: rgb(var(--bg_dark_x));\n --bg_x: 213, 214, 219;\n --bg: rgb(var(--bg_x));\n --bg_highlight_x: 220, 222, 226;\n --bg_highlight: rgb(var(--bg_highlight_x));\n --bg_highlight_dark_x: 195, 197, 201;\n --bg_highlight_dark: rgb(var(--bg_highlight_dark_x));\n --terminal_black_x: 15, 15, 20;\n --terminal_black: rgb(var(--terminal_black_x));\n --fg_x: 52, 59, 88;\n --fg: rgb(var(--fg_x));\n --fg_dark_x: 39, 46, 75;\n --fg_dark: rgb(var(--fg_dark_x));\n --comment_x: 150, 153, 163;\n --comment: rgb(var(--comment_x));\n --blue0_x: 39, 71, 125;\n --blue0: rgb(var(--blue0_x));\n --blue_x: 52, 84, 138;\n --blue: rgb(var(--blue_x));\n --cyan_x: 15, 75, 110;\n --cyan: rgb(var(--cyan_x));\n --magent_hsl: 261 24% 38%;\n --magenta_x: 90, 74, 120;\n --magenta: rgb(var(--magenta_x));\n --pink_hsl: 286 24% 38%;\n --pink_x: rgb(109, 74, 120);\n --pink: var(--pink_x);\n --orange_x: 150, 80, 39;\n --orange: rgb(var(--orange_x));\n --yellow_x: 143, 94, 21;\n --yellow: rgb(var(--yellow_x));\n --green_x: 51, 99, 92;\n --green: rgb(var(--green_x));\n --teal_x: 22, 103, 117;\n --teal: rgb(var(--teal_x));\n --red_x: 140, 67, 81;\n --red: rgb(var(--red_x));\n --red1_x: 115, 42, 56;\n --red1: rgb(var(--red1_x));\n --unknown: #000000;\n\n --link-external-filter: invert(62%) sepia(9%) saturate(266%) hue-rotate(189deg) brightness(97%) contrast(88%);\n}\n\n.theme-dark,\n.theme-light {\n --color_red_rgb: var(--red_x);\n --color-red: var(--red);\n --color_purple_rgb: var(--magenta_x);\n --color-purple: var(--magenta);\n --color_green_rgb: var(--green_x);\n --color-green: var(--green);\n --color_cyan_rgb: var(--cyan_x);\n --color-cyan: var(--cyan);\n --color_blue_rgb: var(--blue_x);\n --color-blue: var(--blue);\n --color_yellow_rgb: var(--yellow_x);\n --color-yellow: var(--yellow);\n --color_orange_rgb: var(--orange_x);\n --color-orange: var(--orange);\n --color_pink_rgb: var(--magenta_x);\n --color-pink: var(--magenta);\n\n --background-primary: var(--bg);\n --background-primary-alt: var(--bg);\n --background-secondary: var(--bg_dark);\n --background-secondary-alt: var(--bg_dark);\n\n --background-modifier-border: var(--bg_highlight);\n --background-modifier-border-focus: var(--bg_highlight);\n --background-modifier-border-hover: var(--bg_highlight);\n --background-modifier-form-field: var(--bg_dark);\n --background-modifier-form-field-highlighted: var(--bg_dark);\n --background-modifier-box-shadow: rgba(0, 0, 0, 0.3);\n --background-modifier-success: var(--green);\n --background-modifier-error: var(--red1);\n --background-modifier-error-hover: var(--red);\n --background-modifier-cover: rgba(var(--bg_dark_x), 0.8);\n --background-modifier-hover: var(--bg_highlight);\n --background-modifier-message: rgba(var(--bg_highlight_x), 0.9);\n --background-modifier-active-hover: var(--bg_highlight);\n\n --text-normal: var(--fg);\n --text-faint: var(--comment);\n --text-muted: var(--fg_dark);\n --text-error: var(--red);\n --text-accent: var(--magenta);\n --text-accent-hover: var(--cyan);\n --text-error: var(--red1);\n --text-error-hover: var(--red);\n --text-selection: var(--unknown);\n --text-on-accent: var(--bg);\n --text-highlight-bg: rgba(var(--orange_x), 0.25);\n --text-selection: rgba(var(--blue0_x), 0.6);\n --bold-color: var(--cyan);\n --italic-color: var(--cyan);\n\n --interactive-normal: var(--bg_dark);\n --interactive-hover: var(--bg);\n --interactive-success: var(--green);\n --interactive-accent: hsl(var(--accent-h), var(--accent-s), var(--accent-l));\n --interactive-accent-hover: var(--blue);\n\n --scrollbar-bg: var(--bg_dark2);\n --scrollbar-thumb-bg: var(--comment);\n --scrollbar-active-thumb-bg: var(--comment);\n --scrollbar-width: 7px;\n\n --h1-color: var(--red);\n --h2-color: var(--yellow);\n --h3-color: var(--green);\n --h4-color: var(--cyan);\n --h5-color: var(--blue);\n --h6-color: var(--magenta);\n\n --border-width: 2px;\n\n --tag-color: var(--magenta);\n --tag-background: rgba(var(--magenta_x), 0.15);\n --tag-color-hover: var(--cyan);\n --tag-background-hover: rgba(var(--cyan_x), 0.15);\n\n --link-color: var(--magenta);\n --link-color-hover: var(--cyan);\n --link-external-color: var(--magenta);\n --link-external-color-hover: var(--cyan);\n\n --checkbox-radius: var(--radius-l);\n --checkbox-color: var(--green);\n --checkbox-color-hover: var(--green);\n --checkbox-marker-color: var(--bg);\n --checkbox-border-color: var(--comment);\n --checkbox-border-color-hover: var(--comment);\n\n --table-header-background: var(--bg_dark2);\n --table-header-background-hover: var(--bg_dark2);\n\n --flashing-background: rgba(var(--blue0_x), 0.3);\n\n --code-normal: var(--fg);\n --code-background: var(--bg_highlight_dark);\n\n --mermaid-note: var(--blue0);\n --mermaid-actor: var(--fg_dark);\n --mermaid-loopline: var(--blue);\n\n --blockquote-background-color: var(--bg_dark);\n --callout-default: var(--blue);\n --callout-info: var(--blue);\n --callout-summary: var(--cyan);\n --callout-tip: var(--cyan);\n --callout-todo: var(--cyan);\n --callout-bug: var(--red);\n --callout-error: var(--red1);\n --callout-fail: var(--red1);\n --callout-example: var(--magenta);\n --callout-important: var(--green);\n --callout-success: var(--teal);\n --callout-question: var(--yellow);\n --callout-warning: var(--orange);\n --callout-quote: var(--fg_dark);\n\n --icon-color-hover: var(--blue);\n --icon-color-focused: var(--magenta);\n --icon-color-active: var(--magenta);\n\n --nav-item-color-hover: var(--fg);\n --nav-item-background-hover: var(--bg_highlight);\n --nav-item-color-active: var(--red);\n --nav-item-background-active: var(--bg_highlight);\n --nav-file-tag: rgba(var(--yellow_x), 0.9);\n --nav-indentation-guide-color: var(--bg_highlight);\n\n --indentation-guide-color: var(--comment);\n --indentation-guide-color-active: var(--comment);\n\n --graph-line: var(--comment);\n --graph-node: var(--fg);\n --graph-node-tag: var(--orange);\n --graph-node-attachment: var(--blue);\n\n --tab-text-color-focused-active: rgba(var(--red_x), 0.8);\n --tab-text-color-focused-active-current: var(--red);\n\n --modal-border-color: var(--bg_highlight);\n --prompt-border-color: var(--bg_highlight);\n --slider-track-background: var(--bg_highlight);\n\n --embed-background: var(--bg_dark);\n --embed-padding: 1.5rem 1.5rem 0.5rem;\n\n --canvas-color: var(--bg_highlight_x);\n\n --toggle-thumb-color: var(--bg);\n\n --image-radius: 0px;\n\n --date-color: var(--blue);\n --date-background-color: var(--bg_highlight);\n}\n\n\n/* Headings (credit: https://github.com/Akifyss/obsidian-border)*/\n\nbody:not(.heading-indicator-off) .markdown-rendered>:is(h1, h2, h3, h4, h5, h6),\nbody:not(.heading-indicator-off) .markdown-preview-sizer>div>:is(h1, h2, h3, h4, h5, h6) {\n position: relative;\n}\n\nbody.h1-divider-on :is(.markdown-preview-sizer>div>h1, .markdown-rendered>h1, .HyperMD-header-1)::after {\n content: \"\";\n position: absolute;\n height: 2px;\n width: 100%;\n right: 0px;\n opacity: 0.5;\n bottom: 0;\n background-image: linear-gradient(to left, var(--h1-color) 30%, transparent 70%);\n}\n\nbody.h2-divider-on :is(.markdown-preview-sizer>div>h2, .markdown-rendered>h2, .HyperMD-header-2)::after {\n content: \"\";\n position: absolute;\n height: 2px;\n width: 100%;\n right: 0px;\n opacity: 0.5;\n bottom: 0;\n background-image: linear-gradient(to left, var(--h2-color) 30%, transparent 70%);\n}\n\nbody.h3-divider-on :is(.markdown-preview-sizer>div>h3, .markdown-rendered>h3, .HyperMD-header-3)::after {\n content: \"\";\n position: absolute;\n height: 2px;\n width: 100%;\n right: 0px;\n opacity: 0.5;\n bottom: 0;\n background-image: linear-gradient(to left, var(--h3-color) 30%, transparent 70%);\n}\n\nbody.h4-divider-on :is(.markdown-preview-sizer>div>h4, .markdown-rendered>h4, .HyperMD-header-4)::after {\n content: \"\";\n position: absolute;\n height: 2px;\n width: 100%;\n right: 0px;\n opacity: 0.5;\n bottom: 0;\n background-image: linear-gradient(to left, var(--h4-color) 30%, transparent 70%);\n}\n\nbody.h5-divider-on :is(.markdown-preview-sizer>div>h5, .markdown-rendered>h5, .HyperMD-header-5)::after {\n content: \"\";\n position: absolute;\n height: 2px;\n width: 100%;\n right: 0px;\n opacity: 0.5;\n bottom: 0;\n background-image: linear-gradient(to left, var(--h5-color) 30%, transparent 70%);\n}\n\nbody.h6-divider-on :is(.markdown-preview-sizer>div>h6, .markdown-rendered>h6, .HyperMD-header-6)::after {\n content: \"\";\n position: absolute;\n height: 2px;\n width: 100%;\n right: 0px;\n opacity: 0.5;\n bottom: 0;\n background-image: linear-gradient(to left, var(--h6-color) 30%, transparent 70%);\n}\n\n/* mobile */\n.is-mobile .suggestion-item.is-selected {\n background-color: var(--bg_highlight);\n}\n\n/* scrollbars */\n::-webkit-scrollbar {\n width: var(--scrollbar-width);\n background-color: transparent !important;\n}\n\n:hover::-webkit-scrollbar-thumb {\n background-color: var(--scrollbar-thumb-bg) !important;\n}\n\n::-webkit-scrollbar-thumb:hover,\n::-webkit-scrollbar-thumb:active {\n background-color: var(--scrollbar-active-thumb-bg) !important;\n}\n\n\n::-webkit-scrollbar:horizontal {\n height: 0;\n background-color: transparent !important;\n}\n\n/* tooltip */\n.tooltip {\n color: var(--fg);\n}\n\n/* links */\n\n.cm-url {\n filter: none !important;\n}\n\n.cm-string.cm-url.external-link {\n filter: var(--link-external-filter) !important;\n}\n\na.external-link {\n filter: none;\n background-image: unset;\n padding-right: 0;\n}\n\na.external-link::after {\n content: url(public/images/874d8b8e340f75575caa.svg);\n filter: var(--link-external-filter);\n padding-left: 2.5px;\n bottom: -1px;\n position: relative;\n}\n\n/* Images */\n\n.image-embed {\n border-radius: var(--image-radius);\n}\n\n/* tables credit: Akifyss/border */\nbody {\n --table-width: 88cqw;\n}\n\n.theme-dark,\n.theme-light .markdown-rendered thead tr {\n background: var(--table-header-background);\n}\n\nbody:not(.table-width-obsidian-default) .markdown-source-view.mod-cm6 .cm-table-widget .table-wrapper,\nbody:not(.table-width-obsidian-default) :is(.markdown-rendered, .markdown-source-view.mod-cm6.is-live-preview) table {\n width: 100% !important;\n}\n\n.table-width-customized .markdown-source-view.mod-cm6.is-live-preview .cm-content>.cm-table-widget,\n.table-width-customized .markdown-rendered .markdown-preview-sizer>div:has(>table) {\n overflow: auto;\n width: var(--table-width) !important;\n margin-left: calc((var(--table-width) - 100%) / -2) !important;\n}\n\n/* notice */\n.notice {\n background-color: var(--bg_dark);\n border: 2px solid var(--bg_highlight);\n}\n\n/* callouts */\n.callout[data-callout=\"quote\"] {\n background-color: rgba(var(--bg_highlight_dark_x), 0.5);\n}\n\n\n.callout[data-callout=\"cite\"] {\n --callout-color: var(--pink_x);\n background-color: color-mix(in srgb, var(--pink_x) 15%, transparent);\n}\n\n/* the following callouts are from Border (https://github.com/Akifyss/obsidian-border/blob/18607ee208d56e1d4faff7419efb9cf7c9cc726e/theme.css#L7710C1-L7799C2) */\n\n.callout-style-1.theme-light {\n --callout-border-width: 1px;\n --callout-border-opacity: 0.25;\n --callout-padding: 0;\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 8px 16px;\n --callout-title-size: inherit;\n --callout-content-padding: 0px 16px;\n --callout-content-background: #FFFFFFBF;\n --callout-content-radius: 0px;\n}\n\n.callout-style-1.theme-dark {\n --callout-border-width: 1px;\n --callout-border-opacity: 0.25;\n --callout-padding: 0;\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 8px 16px;\n --callout-title-size: inherit;\n --callout-content-padding: 0px 16px;\n --callout-content-background: #00000040;\n --callout-content-radius: 0px;\n}\n\n.callout-style-2.theme-light {\n --callout-border-width: 1px;\n --callout-border-opacity: 0.25;\n --callout-padding: 0 6px 6px;\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 8px 16px;\n --callout-title-size: inherit;\n --callout-content-padding: 0px 16px;\n --callout-content-background: #FFFFFFBF;\n --callout-content-radius: 4px;\n}\n\n.callout-style-2.theme-dark {\n --callout-border-width: 1px;\n --callout-border-opacity: 0.25;\n --callout-padding: 0 6px 6px;\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 8px 16px;\n --callout-title-size: inherit;\n --callout-content-padding: 0px 16px;\n --callout-content-background: #00000040;\n --callout-content-radius: 4px;\n}\n\n.callout-style-3:is(.theme-light, .theme-dark) {\n --callout-border-width: 0 0 0 4px;\n --callout-border-opacity: 1;\n --callout-padding: var(--size-4-3) var(--size-4-3) var(--size-4-3) var(--size-4-6);\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 0;\n --callout-title-size: inherit;\n --callout-content-padding: 0;\n --callout-content-background: transparent;\n --callout-content-radius: 0px;\n}\n\n.callout-style-4.theme-light {\n --callout-border-width: 0 0 0 4px;\n --callout-border-opacity: 1;\n --callout-padding: 0;\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 8px 16px;\n --callout-title-size: inherit;\n --callout-content-padding: 0px 16px;\n --callout-content-background: #FFFFFFBF;\n --callout-content-radius: 0px;\n}\n\n.callout-style-4.theme-dark {\n --callout-border-width: 0 0 0 4px;\n --callout-border-opacity: 1;\n --callout-padding: 0;\n --callout-radius: var(--radius-s);\n --callout-title-color: inherit;\n --callout-title-padding: 8px 16px;\n --callout-title-size: inherit;\n --callout-content-padding: 0px 16px;\n --callout-content-background: #00000040;\n --callout-content-radius: 0px;\n}\n\n/* for vim */\n.ͼ2 .cm-panels-bottom {\n border-top: var(--border-width) solid var(--bg_highlight);\n}\n\n.cm-vim-message {\n color: var(--red);\n}\n\n/* kanban */\n.kanban-plugin__lane-action-add {\n color: var(--fg);\n}\n\n.kanban-plugin__lane-action-add:hover {\n color: var(--bg);\n}\n\n.kanban-plugin__autocomplete-item:hover,\n.kanban-plugin__autocomplete-item-active {\n color: hsl(var(--accent-h), var(--accent-s), var(--accent-l));\n background-color: var(--bg_highlight);\n}\n\n/* various-complement */\n.various-complements__suggestion-item::before {\n filter: var(--link-external-filter);\n}\n\n.various-complements__footer::before {\n filter: var(--link-external-filter);\n}\n\n/* Math Jax */\nmjx-math {\n font-size: 110% !important;\n}\n\n/* metadata */\n.cm-atom {\n color: var(--blue);\n}\n\nspan.cm-hmd-frontmatter.cm-meta {\n color: rgba(var(--blue0_x), 0.9);\n}\n\n/* checkboxes (credit: border) */\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"<\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"<\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"<\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\">\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\">\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\">\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"d\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"d\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"d\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"u\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"u\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"u\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"S\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"S\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"S\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"“\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"“\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"“\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"\\\"\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"\\\"\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"\\\"\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"c\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"c\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"c\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"p\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"p\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"p\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"n\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"n\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"n\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"b\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"b\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"b\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"-\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"-\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"-\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"l\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"l\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"l\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"I\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"I\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"I\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"/\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"/\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"/\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"i\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"i\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"i\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"*\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"*\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"*\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"?\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"?\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"?\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"!\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"!\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"!\"]>p>input:checked {\n --checkbox-marker-color: transparent;\n border: none;\n border-radius: 0;\n background-image: none;\n background-color: currentColor;\n -webkit-mask-size: var(--checkbox-icon);\n -webkit-mask-position: 50% 50%\n}\n\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"!\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"!\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"!\"]>p>input:checked {\n --checkbox-color-hover: var(--color-orange);\n color: var(--color-orange);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"?\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"?\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"?\"]>p>input:checked {\n --checkbox-color-hover: var(--color-pink);\n color: var(--color-pink);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"*\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"*\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"*\"]>p>input:checked {\n --checkbox-color-hover: var(--color-yellow);\n color: var(--color-yellow);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"i\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"i\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"i\"]>p>input:checked {\n --checkbox-color-hover: var(--color-cyan);\n color: var(--color-cyan);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"/\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"/\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"/\"]>p>input:checked {\n --checkbox-color-hover: var(--color-yellow);\n color: var(--color-yellow);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"I\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"I\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"I\"]>p>input:checked {\n --checkbox-color-hover: var(--color-orange);\n color: var(--color-orange);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"l\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"l\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"l\"]>p>input:checked {\n --checkbox-color-hover: var(--color-red);\n color: var(--color-red);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"-\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"-\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"-\"]>p>input:checked {\n --checkbox-color-hover: var(--text-faint);\n color: var(--text-faint);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) body:not(.tasks) .markdown-preview-view ul li[data-task=\"x\"].task-list-item.is-checked,\nbody:not(.disable-alternative-checkboxes) body:not(.tasks) .markdown-source-view.mod-cm6 .HyperMD-task-line[data-task]:is([data-task=\"x\"]),\nbody:not(.disable-alternative-checkboxes) body:not(.tasks) li[data-task=\"x\"].task-list-item.is-checked,\nbody:not(.disable-alternative-checkboxes) body:not(.tasks) .markdown-preview-view ul li[data-task=\"-\"].task-list-item.is-checked,\nbody:not(.disable-alternative-checkboxes) body:not(.tasks) .markdown-source-view.mod-cm6 .HyperMD-task-line[data-task]:is([data-task=\"-\"]),\nbody:not(.disable-alternative-checkboxes) body:not(.tasks) li[data-task=\"-\"].task-list-item.is-checked {\n color: var(--text-faint);\n text-decoration: line-through solid var(--text-faint) 1px\n}\n\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"b\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"b\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"b\"]>p>input:checked {\n --checkbox-color-hover: var(--color-blue);\n color: var(--color-blue);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"n\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"n\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"n\"]>p>input:checked {\n --checkbox-color-hover: var(--color-cyan);\n color: var(--color-cyan);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"p\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"p\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"p\"]>p>input:checked {\n --checkbox-color-hover: var(--color-green);\n color: var(--color-green);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"c\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"c\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"c\"]>p>input:checked {\n --checkbox-color-hover: var(--color-red);\n color: var(--color-red);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"“\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"“\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"“\"]>p>input:checked,\nbody:not(.disable-alternative-checkboxes) input[data-task=\"\\\"\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"\\\"\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"\\\"\"]>p>input:checked {\n --checkbox-color-hover: var(--color-purple);\n color: var(--color-purple);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"S\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"S\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"S\"]>p>input:checked {\n --checkbox-color-hover: var(--color-green);\n color: var(--color-green);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"u\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"u\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"u\"]>p>input:checked {\n --checkbox-color-hover: var(--color-green);\n color: var(--color-green);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"d\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"d\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"d\"]>p>input:checked {\n --checkbox-color-hover: var(--color-red);\n color: var(--color-red);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\">\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\">\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\">\"]>p>input:checked {\n --checkbox-color-hover: var(--color-pink);\n color: var(--color-pink);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\nbody:not(.disable-alternative-checkboxes) input[data-task=\"<\"]:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"<\"]>input:checked,\nbody:not(.disable-alternative-checkboxes) li[data-task=\"<\"]>p>input:checked {\n --checkbox-color-hover: var(--color-blue);\n color: var(--color-blue);\n -webkit-mask-image: url('data:image/svg+xml;utf8,');\n}\n\n\n/* style settings */\n\n.style-settings-container .themed-color-wrapper {\n display: flex;\n gap: var(--size-4-2);\n}\n\n.style-settings-container .themed-color-wrapper>div+div {\n margin-top: 0;\n}\n\n.style-settings-container {\n margin-left: var(--size-4-1);\n padding-left: var(--size-4-8);\n border-left: 1px solid var(--divider-color);\n}\n\n/* printing */\n\n@media print {\n .theme-light {\n --blockquote-background-color: rgba(var(--bg_dark_x), 0.25);\n --code-background: rgba(var(--bg_highlight_dark_x), 0.25);\n --table-header-background: rgba(var(--bg_dark2_x), 0.25);\n }\n}\n\n\n/* @settings\n\nname: Appearance\nid: Appearance\nsettings:\n -\n id: colors\n title: Colors\n type: heading\n level: 1\n collapsed: true\n -\n id: accent\n title: Accent Color\n type: variable-themed-color\n format: hsl-split\n opacity: false\n default-light: '#0F87CC'\n default-dark: '#80D0FF'\n -\n id: Extended-colors\n title: Extended Colors\n type: heading\n level: 2\n collapsed: true\n -\n id: red\n title: Red\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(115, 42, 56)'\n default-dark: 'rgb(219, 75, 75)'\n -\n id: red1\n title: Red1\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(140, 67, 81)'\n default-dark: 'rgb(255, 117, 127)'\n -\n id: green\n title: Green\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(51, 99, 92)'\n default-dark: 'rgb(158, 206, 106)'\n -\n id: cyan\n title: Cyan\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(15, 75, 110)'\n default-dark: 'rgb(125, 207, 255)'\n -\n id: blue\n title: Blue\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(52, 84, 138)'\n default-dark: 'rgb(122, 162, 247)'\n -\n id: yellow\n title: Yellow\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(143, 94, 21)'\n default-dark: 'rgb(224, 175, 104)'\n -\n id: orange\n title: Orange\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(150, 80, 39)'\n default-dark: 'rgb(255, 158, 100)'\n -\n id: magenta\n title: Magenta\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(90, 74, 120)'\n default-dark: 'rgb(187, 154, 247)'\n -\n id: pink\n title: Pink\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(109, 74, 120)'\n default-dark: 'rgb(217, 0, 105)'\n -\n id: Background-colors\n title: Background Colors\n type: heading\n level: 1\n collapsed: true\n -\n id: bg\n title: Background Primary\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(213, 214, 219)'\n default-dark: 'rgb(26, 27, 38)'\n -\n id: bg_dark\n title: Background Dark\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(203, 204, 209)'\n default-dark: 'rgb(22, 22, 30)'\n -\n id: bg_highlight\n title: Background Highlight\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(220, 222, 226)'\n default-dark: 'rgb(41, 46, 66)'\n -\n id: bg_highlight_dark\n title: Background Highlight Dark\n description: 'Used for code bg'\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(195, 197, 201)'\n default-dark: 'rgb(36, 40, 59)'\n -\n id: bg_dark2\n title: Background Dark2\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: 'rgb(188, 189, 194)'\n default-dark: 'rgb(15, 15, 20)'\n -\n id: foreground-header\n title: Foreground\n type: heading\n level: 2\n collapsed: true\n -\n id: text-color-heading\n title: Text / Icon Color\n type: heading\n level: 3\n collapsed: true\n -\n id: text-normal\n title: Text Normal\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-muted\n title: Text Muted\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-faint\n title: Text Faint\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-accent\n title: Text Accent\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-accent-hover\n title: Text Accent Hover\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-on-accent\n title: Text on Accent\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-on-accent-inverted\n title: Text on Accent Inverted\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-error\n title: Text Error\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-warning\n title: Text Warning\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-success\n title: Text Success\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: interactive-heading\n title: Interactive color\n type: heading\n level: 3\n collapsed: true\n -\n id: interactive-normal\n title: Interactive Normal\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: interactive-hover\n title: Interactive Hover\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: interactive-accent\n title: Interactive Accent\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: interactive-accent-hover\n title: Interactive Accent Hover\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: interactive-accent-hsl\n title: Interactive Accent HSL\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n*/\n\n/* @settings\n\nname: Editor\nid: Editor\nsettings:\n -\n id: headings\n title: Tabs\n type: heading\n level: 1\n collapsed: true\n -\n id: tab-text-color-focused-active\n title: Tab Focus Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: tab-text-color-focused-active-current\n title: Current Tab Focus Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: headings\n title: Headings\n type: heading\n level: 1\n collapsed: true\n -\n id: level-1-heading\n title: Level 1\n type: heading\n level: 2\n collapsed: true\n -\n\t\tid: h1-divider-on\n\t\ttitle: disable H1 divider\n\t\ttype: class-toggle\n\t\tdefault: false\n -\n id: h1-font\n title: H1 Font\n type: variable-text\n default: ''\n -\n id: h1-size\n title: H1 Font Size\n description: Any CSS font-size value\n type: variable-text\n default: ''\n -\n id: h1-weight\n title: H1 Font Weight\n description: Value from 100 - 900\n type: variable-text\n default: 700\n\t-\n id: h1-color\n title: H1 Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: level-2-heading\n title: Level 2\n type: heading\n level: 2\n collapsed: true\n -\n\t\tid: h2-divider-on\n\t\ttitle: disable H2 divider\n\t\ttype: class-toggle\n\t\tdefault: false\n -\n id: h2-font\n title: H2 Font\n type: variable-text\n default: ''\n -\n id: h2-size\n title: H2 Font Size\n description: Any CSS font-size value\n type: variable-text\n default: ''\n -\n id: h2-weight\n title: H2 Font Weight\n description: Value from 100 - 900\n type: variable-text\n default: 600\n\t-\n id: h2-color\n title: H2 Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: level-3-heading\n title: Level 3\n type: heading\n level: 2\n collapsed: true\n -\n\t\tid: h3-divider-on\n\t\ttitle: disable H3 divider\n\t\ttype: class-toggle\n\t\tdefault: false\n -\n id: h3-font\n title: H3 Font\n type: variable-text\n default: ''\n -\n id: h3-size\n title: H3 Font Size\n description: Any CSS font-size value\n type: variable-text\n default: ''\n -\n id: h3-weight\n title: H3 Font Weight\n description: Value from 100 - 900\n type: variable-text\n default: 600\n\t-\n id: h3-color\n title: H3 Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: level-4-heading\n title: Level 4\n type: heading\n level: 2\n collapsed: true\n -\n\t\tid: h4-divider-on\n\t\ttitle: disable H4 divider\n\t\ttype: class-toggle\n\t\tdefault: false\n -\n id: h4-font\n title: H4 Font\n type: variable-text\n default: ''\n -\n id: h4-size\n title: H4 Font Size\n description: Any CSS font-size value\n type: variable-text\n default: ''\n -\n id: h4-weight\n title: H4 Font Weight\n description: Value from 100 - 900\n type: variable-text\n default: 600\n\t-\n id: h4-color\n title: H4 Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: level-5-heading\n title: Level 5\n type: heading\n level: 2\n collapsed: true\n -\n\t\tid: h5-divider-on\n\t\ttitle: disable H5 divider\n\t\ttype: class-toggle\n\t\tdefault: false\n -\n id: h5-font\n title: H5 Font\n type: variable-text\n default: ''\n -\n id: h5-size\n title: H5 Font Size\n description: Any CSS font-size value\n type: variable-text\n default: ''\n -\n id: h5-weight\n title: H5 Font Weight\n description: Value from 100 - 900\n type: variable-text\n default: 600\n\t-\n id: h5-color\n title: H5 Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: level-6-heading\n title: Level 6\n type: heading\n level: 2\n collapsed: true\n -\n\t\tid: h6-divider-on\n\t\ttitle: disable H6 divider\n\t\ttype: class-toggle\n\t\tdefault: false\n -\n id: h6-font\n title: H6 Font\n type: variable-text\n default: ''\n -\n id: h6-size\n title: H6 Font Size\n description: Any CSS font-size value\n type: variable-text\n default: ''\n -\n id: h6-weight\n title: H6 Font Weight\n description: Value from 100 - 900\n type: variable-text\n default: 600\n\t-\n id: h6-color\n title: H6 Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: text-heading\n title: Text\n type: heading\n level: 1\n collapsed: true\n -\n id: bold-color\n title: Bold Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: italic-color\n title: Italic Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: link-heading\n title: Links\n type: heading\n level: 1\n collapsed: true\n -\n id: link-color\n title: Link Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: link-color-hover\n title: Link Hover Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: link-decoration\n\t\ttitle: Link decoration\n description: Any CSS text-decoration-line value\n\t\ttype: variable-text\n\t\tdefault: underline\n -\n id: link-decoration-hover\n title: Link Hover decoration\n description: Any CSS text-decoration-line value\n type: variable-text\n default: underline\n -\n id: link-decoration-thickness\n title: Link decoration thickness\n description: Any CSS text-decoration-thickness value\n type: variable-text\n default: auto\n -\n id: external-link-heading\n title: External Link\n type: heading\n level: 3\n collapsed: true\n -\n id: link-external-color\n title: External Link Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: link-external-color-hover\n title: External Link Hover Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: link-external-decoration\n title: External Link decoration\n description: Any CSS text-decoration-line value\n type: variable-text\n default: underline\n -\n id: link-external-decoration-hover\n title: External Link Hover decoration\n description: Any CSS text-decoration-line value\n type: variable-text\n default: underline\n -\n id: link-external-filter\n title: External Link Filter\n description: Any CSS filter value\n type: variable-text\n default: invert(35%) sepia(28%) saturate(681%) hue-rotate(192deg) brightness(94%) contrast(85%)\n -\n id: unresolved-link-heading\n title: Unresolved Link\n type: heading\n level: 3\n collapsed: true\n -\n id: link-unresolved-color\n title: Unresolved Link Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n\t\tid: link-unresolved-opacity\n\t\ttitle: Unresolved link opacity\n\t\ttype: variable-number-slider\n\t\tdefault: 0.7\n\t\tmin: 0.25\n\t\tmax: 1\n\t\tstep: 0.05\n -\n id: link-unresolved-filter\n title: Unresolved Link Filter\n description: Any CSS filter value\n type: variable-text\n default: none\n -\n id: link-unresolved-decoration\n title: Unresolved Link decoration\n description: Any CSS text-decoration-line value\n type: variable-text\n default: solid\n -\n id: link-unresolved-decoration-color\n title: Unresolved Link decoration Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: lists-heading\n title: Lists\n type: heading\n level: 2\n collapsed: true\n -\n id: list-indent\n title: List Indent\n type: variable-text\n default: 2.25em\n -\n\t\tid: list-spacing\n\t\ttitle: list spacing\n\t\ttype: variable-text\n\t\tdefault: 0.075em\n -\n\t\tid: list-bullet-size\n\t\ttitle: list marker size\n\t\ttype: variable-text\n\t\tdefault: 0.3em\n\t-\n\t\tid: list-marker-color\n\t\ttitle: list marker color\n\t\ttype: variable-themed-color\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n\t\tid: list-marker-color-hover\n\t\ttitle: list marker color (hover)\n\t\ttype: variable-themed-color\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n\t\tid: list-marker-color-collapsed\n\t\ttitle: list marker color (collapsed)\n\t\ttype: variable-themed-color\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: Tags\n title: Tags\n type: heading\n collapsed: true\n level: 2\n -\n\t\tid: tag-color\n\t\ttitle: Tag text color\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n\t-\n\t\tid: tag-background\n\t\ttitle: Tag background color\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n\t-\n\t\tid: tag-background-hover\n\t\ttitle: Tag background color (hover)\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: Dates\n title: Dates\n type: heading\n collapsed: true\n level: 2\n -\n id: date-color\n\t\ttitle: Tag text color\n\t\ttype: variable-themed-color\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n\t\tid: date-background-color\n\t\ttitle: Date background color\n\t\ttype: variable-themed-color\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: table-heading\n title: Tables\n type: heading\n level: 2\n collapsed: true\n -\n id: table-border-width\n title: Table Border Width\n type: variable-number-slider\n default: 1\n min: 0\n max: 20\n step: 1\n format: px\n -\n id: table-border-color\n title: Table Border Color\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: code-headigeng\n title: Code\n type: heading\n level: 2\n collapsed: true\n -\n id: code-background\n title: Code Background\n type: variable-themed-color\n opacity: true\n format: hex\n default-light: '#'\n default-dark: '#'\n -\n\t\tid: code-normal\n\t\ttitle: Code Normal\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n\t\tid: code-comment\n\t\ttitle: Code Comment\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-function\n\t\ttitle: Code Function\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-important\n\t\ttitle: Code Important\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-keyword\n\t\ttitle: Code Keyword\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-operator\n\t\ttitle: Code Operator\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-property\n\t\ttitle: Code Property\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-punctuation\n\t\ttitle: Code Punctuation\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-string\n\t\ttitle: Code String\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-tag\n\t\ttitle: Code Tag\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: code-value\n\t\ttitle: Code Value\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: blockquote-heading\n title: Blockquote\n type: heading\n level: 2\n collapsed: true\n -\n id: blockquote-border-thickness\n title: Blockquote Border Thickness\n type: variable-number-slider\n default: 3\n min: 0\n max: 20\n step: 1\n format: px\n -\n id: blockquote-border-color\n title: Blockquote Border Color\n type: variable-themed-color\n format: hex\n default-light: '#'\n default-dark: '#'\n -\n id: blockquote-color\n title: Blockquote Color\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: blockquote-background-color\n title: Blockquote Background Color\n type: variable-themed-color\n format: hex\n default-light: '#'\n default-dark: '#'\n -\n id: checkbox-heading\n title: Checkboxes\n type: heading\n level: 2\n collapsed: true\n -\n id: disable-alternative-checkboxes\n title: Disable Alternative Checkboxes\n description: Enable this if you are using your own implementation via a CSS Snippet.\n default: false\n type: class-toggle\n -\n id: checkbox-radius\n title: Checkbox Radius\n description: Any CSS border-radius value\n type: variable-text\n default: ''\n -\n id: checkbox-color\n title: Checkbox Color\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: checkbox-color-hover\n title: Checkbox Hover Color\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: checkbox-marker-color\n title: Checkbox Marker Color\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: checkbox-border-color\n title: Checkbox Border Color\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: checkbox-border-color-hover\n title: Checkbox Border Hover Color\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-heading\n title: Callouts\n type: heading\n level: 2\n collapsed: true\n -\n id: callout-style-select\n title: Callout Style\n type: class-select\n allowEmpty: false\n default: callout-style-customize\n options:\n - label: Customize\n value: callout-style-customize\n - label: style 1\n value: callout-style-1\n - label: style 2\n value: callout-style-2\n - label: style 3\n value: callout-style-3\n - label: style 4\n value: callout-style-4\n -\n\t\tid: callout-border-width\n\t\ttitle: Callout Border Width\n type: variable-text\n description: Any CSS border-width value\n default: 0px\n -\n id: callout-border-opacity\n title: Callout Border Opacity\n type: variable-number-slider\n default: 0.25\n min: 0\n max: 1\n step: 0.05\n -\n\t\tid: callout-padding\n\t\ttitle: Callout Padding\n description: Accepts any CSS padding value\n\t\ttype: variable-text\n\t\tdefault: ''\n -\n id: callout-radius\n title: Callout Radius\n description: Input your border-radius value here\n type: variable-text\n default: ''\n\t-\n\t\tid: callout-title-color\n\t\ttitle: Callout Title Color\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n\t\tid: callout-title-padding\n\t\ttitle: Callout Title Padding\n description: Any CSS font-size value\n\t\ttype: variable-text\n\t\tdefault: ''\n -\n\t\tid: callout-title-size\n\t\ttitle: Callout Title Size\n description: Any CSS font-size value\n\t\ttype: variable-text\n\t\tdefault: ''\n -\n\t\tid: callout-content-padding\n\t\ttitle: Callout Content Padding\n description: Any CSS font-size value\n\t\ttype: variable-text\n\t\tdefault: ''\n -\n\t\tid: callout-content-background\n\t\ttitle: Callout Content Background\n\t\ttype: variable-themed-color\n\t\topacity: true\n\t\tformat: hex\n\t\tdefault-light: '#'\n\t\tdefault-dark: '#'\n -\n id: callout-content-radius\n title: Callout Content Radius\n description: Input your border-radius value here\n type: variable-text\n default: ''\n -\n id: callout-color-heading\n title: Callout Colors\n type: heading\n level: 3\n collapsed: true\n -\n id: callout-default\n title: Default Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-info\n title: Info Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-summary\n title: Summary Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-tip\n title: Tip Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-todo\n title: Todo Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-bug\n title: Bug Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-error\n title: Error Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-fail\n title: Fail Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-example\n title: Example Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-important\n title: Important Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-success\n title: Success Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-question\n title: Question Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-warning\n title: Warning Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: callout-quote\n title: Quote Callout\n type: variable-themed-color\n format: hex\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: embed-heading\n title: Embeds\n type: heading\n level: 2\n collapsed: true\n -\n id: embed-background\n title: Embed Background\n type: variable-themed-color\n opacity: true\n format: hex\n default-light: '#'\n default-dark: '#'\n -\n id: embed-padding\n title: Embed Padding\n description: Any CSS padding value\n type: variable-text\n default: ''\n -\n id: embed-border-radius\n title: Embed Border Radius\n description: Any CSS border-radius value\n type: variable-text\n default: ''\n -\n id: embed-font-style\n title: Embed Font Style\n description: Any CSS font-style value\n type: variable-text\n default: ''\n -\n id: image-heading\n title: Images\n type: heading\n level: 2\n collapsed: true\n -\n id: image-radius\n title: Image Radius\n description: Any CSS border-radius value\n type: variable-text\n default: ''\n -\n id: scrollbar-heading\n title: Scrollbar\n type: heading\n level: 2\n collapsed: true\n -\n id: scrollbar-bg\n title: Scrollbar Background\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: scrollbar-thumb-bg\n title: Scrollbar Thumb Background\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: scrollbar-active-thumb-bg\n title: Scrollbar Active Thumb Background\n type: variable-themed-color\n format: rgb\n opacity: false\n default-light: '#'\n default-dark: '#'\n -\n id: scrollbar-width\n title: Scrollbar Width\n type: variable-number-slider\n default: 7\n min: 0\n max: 20\n step: 1\n format: px\n -\n*/\n", "obsidian-vault/_attachments/.gitkeep": "\n", "obsidian-vault/_templates/.gitkeep": "\n", - "repository-files.txt": ".gitignore\n.github/pull_request_template.md\n.github/workflows/quality.yml\n1bite\nAGENTS.md\nLICENSE\nMakefile\nREADME.md\nVERSION\napps/README.md\napps/qbittorrent/README.md\napps/qbittorrent/compose.yaml\napps/qbittorrent/qbt.py\napps/qbittorrent/qbt.sh\napps/qbittorrent/smoke-test.py\nassets/logo.png\nconfig/casks.tsv\nconfig/claude-settings.json\nconfig/codex-config.toml\nconfig/download.curlrc\nconfig/env.zsh\nconfig/font-casks.tsv\nconfig/formulae.txt\nconfig/git-defaults.json\nconfig/gitignore-global\nconfig/iterm2-profile.json\nconfig/kiro-permissions.json\nconfig/obsidian-vault/.gitignore\nconfig/obsidian-vault/.obsidian/app.json\nconfig/obsidian-vault/.obsidian/appearance.json\nconfig/obsidian-vault/.obsidian/core-plugins.json\nconfig/obsidian-vault/.obsidian/graph.json\nconfig/obsidian-vault/.obsidian/snippets/wide.css\nconfig/obsidian-vault/.obsidian/templates.json\nconfig/obsidian-vault/.obsidian/themes/Tokyo Night/LICENSE\nconfig/obsidian-vault/.obsidian/themes/Tokyo Night/manifest.json\nconfig/obsidian-vault/.obsidian/themes/Tokyo Night/theme.css\nconfig/obsidian-vault/_attachments/.gitkeep\nconfig/obsidian-vault/_templates/.gitkeep\nconfig/repository-files.txt\nconfig/shell.zsh\nconfig/sources.tsv\nconfig/vimrc\nconfig/vscode-extensions.txt\nconfig/vscode-settings.json\nconfig/zsh/development.zsh\nconfig/zsh/framework.zsh\nconfig/zsh/git-functions.zsh\nconfig/zsh/media.zsh\nconfig/zsh/options.zsh\nconfig/zsh/terminal.zsh\nconfig/zsh/tools.zsh\nconfig/zsh/utilities.zsh\ndocs/automation.md\ndocs/development.md\ndocs/installation.md\ndocs/maintenance.md\ndocs/obsidian.md\ndocs/result.schema.json\ndocs/shell-editor.md\ndocs/sources.md\ndocs/troubleshooting.md\ndocs/updates.schema.json\nscripts/check-change.py\nscripts/check-format.py\nscripts/check-repository.py\nscripts/check-updates.py\nscripts/configure.py\nscripts/desktop.py\nscripts/docker-smoke.sh\nscripts/editor.sh\nscripts/inventory.py\nscripts/kiro-shell.py\nscripts/manual.py\nscripts/ob.py\nscripts/official_ai.py\nscripts/plain-log.awk\nscripts/prepare-doubao.py\nscripts/prepare-sogou.py\nscripts/quality.sh\nscripts/runtime-smoke.py\nscripts/session.sh\nscripts/welcome.sh\nscripts/verify.sh\ntests/fixtures/config-golden.json\ntests/test_desktop.py\ntests/test_doubao.py\ntests/test_editor.py\ntests/test_interruptions.py\ntests/test_kiro_shell.py\ntests/test_maintenance.py\ntests/test_obsidian.py\ntests/test_official_ai.py\ntests/test_qbittorrent.py\ntests/test_repository.py\ntests/test_session.py\ntests/test_setup.py\ntests/test_sogou.py\ntests/test_welcome.py\n", + "repository-files.txt": ".gitignore\n.github/pull_request_template.md\n.github/workflows/quality.yml\n1bite\nAGENTS.md\nLICENSE\nMakefile\nREADME.md\nVERSION\napps/README.md\napps/qbittorrent/README.md\napps/qbittorrent/compose.yaml\napps/qbittorrent/qbt.py\napps/qbittorrent/qbt.sh\napps/qbittorrent/smoke-test.py\nassets/logo.png\nconfig/casks.tsv\nconfig/claude-settings.json\nconfig/codex-config.toml\nconfig/download.curlrc\nconfig/env.zsh\nconfig/font-casks.tsv\nconfig/formulae.txt\nconfig/git-defaults.json\nconfig/gitignore-global\nconfig/iterm2-profile.json\nconfig/kiro-permissions.json\nconfig/obsidian-vault/.gitignore\nconfig/obsidian-vault/.obsidian/app.json\nconfig/obsidian-vault/.obsidian/appearance.json\nconfig/obsidian-vault/.obsidian/core-plugins.json\nconfig/obsidian-vault/.obsidian/graph.json\nconfig/obsidian-vault/.obsidian/snippets/wide.css\nconfig/obsidian-vault/.obsidian/templates.json\nconfig/obsidian-vault/.obsidian/themes/Tokyo Night/LICENSE\nconfig/obsidian-vault/.obsidian/themes/Tokyo Night/manifest.json\nconfig/obsidian-vault/.obsidian/themes/Tokyo Night/theme.css\nconfig/obsidian-vault/_attachments/.gitkeep\nconfig/obsidian-vault/_templates/.gitkeep\nconfig/repository-files.txt\nconfig/shell.zsh\nconfig/sources.tsv\nconfig/vimrc\nconfig/vscode-extensions.txt\nconfig/vscode-settings.json\nconfig/zsh/development.zsh\nconfig/zsh/framework.zsh\nconfig/zsh/git-functions.zsh\nconfig/zsh/media.zsh\nconfig/zsh/options.zsh\nconfig/zsh/terminal.zsh\nconfig/zsh/tools.zsh\nconfig/zsh/utilities.zsh\ndocs/automation.md\ndocs/battery.md\ndocs/battery.schema.json\ndocs/development.md\ndocs/installation.md\ndocs/maintenance.md\ndocs/obsidian.md\ndocs/result.schema.json\ndocs/shell-editor.md\ndocs/sources.md\ndocs/troubleshooting.md\ndocs/updates.schema.json\nscripts/battery.sh\nscripts/check-change.py\nscripts/check-format.py\nscripts/check-repository.py\nscripts/check-updates.py\nscripts/configure.py\nscripts/desktop.py\nscripts/docker-smoke.sh\nscripts/editor.sh\nscripts/inventory.py\nscripts/kiro-shell.py\nscripts/manual.py\nscripts/ob.py\nscripts/official_ai.py\nscripts/plain-log.awk\nscripts/prepare-doubao.py\nscripts/prepare-sogou.py\nscripts/quality.sh\nscripts/runtime-smoke.py\nscripts/session.sh\nscripts/welcome.sh\nscripts/verify.sh\ntests/fixtures/battery/pmset-log-batt-uppercase.txt\ntests/fixtures/battery/pmset-log-batt.txt\ntests/fixtures/config-golden.json\ntests/test_battery.py\ntests/test_desktop.py\ntests/test_doubao.py\ntests/test_editor.py\ntests/test_interruptions.py\ntests/test_kiro_shell.py\ntests/test_maintenance.py\ntests/test_obsidian.py\ntests/test_official_ai.py\ntests/test_qbittorrent.py\ntests/test_repository.py\ntests/test_session.py\ntests/test_setup.py\ntests/test_sogou.py\ntests/test_welcome.py\n", "shell.zsh": "# Managed by 1bite. Personal overrides belong after the source in .zshrc.\nsource \"${${(%):-%N}:A:h}/env.zsh\"\n[[ -o interactive ]] || return 0\n\nsetup_shell_directory=\"${${(%):-%N}:A:h}\"\nfor setup_shell_module in framework options tools development utilities media git-functions terminal; do\n setup_shell_path=\"$setup_shell_directory/zsh/$setup_shell_module.zsh\"\n [[ -r \"$setup_shell_path\" ]] || { print -u2 \"1bite: missing Zsh module: $setup_shell_path\"; return 1; }\n source \"$setup_shell_path\" || return\ndone\nunset setup_shell_directory setup_shell_module setup_shell_path\n", "sources.tsv": "homebrew\tinstaller\thttps://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh\nclaude\tinstaller\thttps://claude.ai/install.sh\ncodex\tinstaller\thttps://chatgpt.com/codex/install.sh\nkiro-cli\tinstaller\thttps://cli.kiro.dev/install\nchatgpt\tdocumentation\thttps://learn.chatgpt.com/docs/app\nkiro\tdocumentation\thttps://kiro.dev/downloads/\nchatgpt-download\tdistribution\thttps://persistent.oaistatic.com/codex-app-prod/Codex.dmg\nchatgpt-distribution\tdistribution\thttps://persistent.oaistatic.com/codex-app-prod/\nchatgpt-releases\tmetadata\thttps://persistent.oaistatic.com/codex-app-prod/appcast.xml\nkiro-distribution\tdistribution\thttps://prod.download.desktop.kiro.dev/\nkiro-cli-distribution\tdistribution\thttps://prod.download.cli.kiro.dev/stable/\nkiro-cli-releases\tmetadata\thttps://prod.download.cli.kiro.dev/stable/latest/manifest.json\nclaude-releases\tmetadata\thttps://downloads.claude.ai/claude-code-releases/latest\ncodex-releases\tmetadata\thttps://releases.openai.com/codex/channels/latest\nohmyzsh\tinstaller\thttps://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh\npowerlevel10k\tgit\thttps://github.com/romkatv/powerlevel10k.git\nneofetch\tinstaller\thttps://raw.githubusercontent.com/dylanaraps/neofetch/7.1.0/neofetch\nneofetch-sha256\tchecksum\t3dc33493e54029fb1528251552093a9f9a2894fcf94f9c3a6f809136a42348c7\ndoubao-input-download\tdistribution\thttps://lf-wave.doubaocdn.com/obj/doubao-ime/app/macos/\ndoubao-input-metadata\tmetadata\thttps://formulae.brew.sh/api/cask/doubaoime.json\nsogou-download\tdistribution\thttps://ime.gtimg.com/pc/\nhomebrew-api\tconnectivity\thttps://formulae.brew.sh/api/formula/go.json\ngithub\tconnectivity\thttps://github.com\nghcr\tconnectivity\thttps://ghcr.io/v2/\nnpm\tconnectivity\thttps://registry.npmjs.org/\nanthropic\tconnectivity\thttps://claude.ai/install.sh\nopenai\tconnectivity\thttps://chatgpt.com/codex/install.sh\nmaintenance-catalog\tmetadata\thttps://formulae.brew.sh/api/\nmaintenance-aliases\tmetadata\thttps://raw.githubusercontent.com/Homebrew/homebrew-core/HEAD/Aliases/\nmaintenance-gallery\tmetadata\thttps://marketplace.visualstudio.com/_apis/public/gallery/extensionquery\nmaintenance-homebrew\tmetadata\thttps://api.github.com/repos/Homebrew/brew/releases/latest\nmaintenance-homebrew-release\tmetadata\thttps://github.com/Homebrew/brew/releases/latest\nmaintenance-one-bite\tgit\thttps://github.com/1set/1bite.git\nmaintenance-ohmyzsh\tgit\thttps://github.com/ohmyzsh/ohmyzsh.git\nmaintenance-charm\tmetadata\thttps://raw.githubusercontent.com/charmbracelet/homebrew-tap/master/\ngoogle-chrome-download\tdistribution\thttps://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg\ndocker-desktop-download\tdistribution\thttps://desktop.docker.com/mac/main/arm64/Docker.dmg\nclaude-desktop-releases\tmetadata\thttps://downloads.claude.ai/releases/darwin/universal/RELEASES.json\nclaude-desktop-distribution\tdistribution\thttps://downloads.claude.ai/releases/darwin/universal/\nclaude-desktop-download\tdistribution\thttps://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect\n", "vimrc": "\" Managed by 1bite. Personal overrides belong after the source line in ~/.vimrc.\nscriptencoding utf-8\nset encoding=utf-8\n\nset modelines=0\n\nset backspace=2\nset nocompatible\n\nset nobackup\nset nowritebackup\nset noswapfile\nset noundofile\n\nsyntax on\nset number\n\nset ruler\nset smartindent shiftwidth=4\nset tabstop=4\nset expandtab\n\nset list\nset listchars=tab:»·,trail:·,extends:>,precedes:<\n\ncom E e.\n\ncab W! w!\ncab Q! q!\ncab Wq wq\ncab Wa wa\ncab wQ wq\ncab WQ wq\ncab W w\ncab Q q\n", diff --git a/tests/test_battery.py b/tests/test_battery.py new file mode 100644 index 0000000..1693f54 --- /dev/null +++ b/tests/test_battery.py @@ -0,0 +1,1030 @@ +import hashlib +import fcntl +import json +import os +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts/battery.sh' +FIXTURES = ROOT / 'tests/fixtures/battery' +PRIVATE_SENTINELS = ('SECRET-SERIAL-123', 'private-host.example', 'private-user', + 'ghp_not_a_real_token') + + +class BatteryDiagnosticsTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='battery diagnostic ') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.home = self.root / 'home' + self.state = self.root / 'state' + self.tmp = self.root / 'tmp' + self.commands = self.root / 'commands' + for directory in (self.home, self.tmp, self.commands): + directory.mkdir(parents=True) + self.calls = self.root / 'calls.txt' + self.live = self.root / 'pmset-batt.txt' + self.live.write_text( + "Now drawing from 'Battery Power'\n" + " -InternalBattery-0 (id=SECRET-SERIAL-123)\t050%; discharging; " + "2:00 remaining present: true\n" + ) + self.log_fixture = FIXTURES / 'pmset-log-batt.txt' + self._write_commands() + + def _write_executable(self, name, body): + path = self.commands / name + path.write_text('#!/bin/bash\nset -eu\n' + body) + path.chmod(0o755) + return path + + def _write_commands(self): + self.pmset = self._write_executable( + 'pmset', + 'printf "pmset:%s\\n" "$*" >>"$BATTERY_CALLS"\n' + 'case "$*" in\n' + ' "-g batt") cat "$BATTERY_LIVE_FIXTURE" ;;\n' + ' "-g log") cat "$BATTERY_LOG_FIXTURE" ;;\n' + ' *) exit 64 ;;\n' + 'esac\n', + ) + self.profiler = self._write_executable( + 'system_profiler', + 'printf "system_profiler:%s\\n" "$*" >>"$BATTERY_CALLS"\n' + "cat <<'EOF'\n" + 'Power:\n' + ' Battery Information:\n' + ' Model Information:\n' + ' Serial Number: SECRET-SERIAL-123\n' + ' Manufacturer: private-user\n' + ' Health Information:\n' + ' Cycle Count: 0012\n' + ' Condition: Normal\n' + ' Maximum Capacity: 087%\n' + ' Hostname: private-host.example\n' + ' Credential: ghp_not_a_real_token\n' + ' UPS Information:\n' + ' Cycle Count: 9999\n' + ' Condition: Replace Now\n' + ' Maximum Capacity: 001%\n' + 'EOF\n', + ) + self.ioreg = self._write_executable( + 'ioreg', + 'printf "ioreg:%s\\n" "$*" >>"$BATTERY_CALLS"\n' + "cat <<'EOF'\n" + '+-o AppleSmartBattery \n' + ' | | "BatteryInstalled" = Yes\n' + ' | | "CycleCount" = 9999\n' + ' | | "AppleRawMaxCapacity" = 05000\n' + ' | | "DesignCapacity" = 06000\n' + ' | | "Voltage" = 12000\n' + ' | | "Amperage" = 5\n' + ' | | "InstantAmperage" = 18446744073709550206\n' + ' | | "BatterySerialNumber" = "SECRET-SERIAL-123"\n' + ' | | "Owner" = "private-user"\n' + ' | | "Token" = "ghp_not_a_real_token"\n' + 'EOF\n', + ) + self.date = self._write_executable( + 'date', + 'case "$*" in\n' + ' "-u +%s %Y-%m-%dT%H:%M:%SZ") printf "1788267600 2026-09-01T13:00:00Z\\n" ;;\n' + ' "-u +%Y%m%dT%H%M%SZ") printf "20260901T130000Z\\n" ;;\n' + ' *) exit 64 ;;\n' + 'esac\n', + ) + for forbidden in ('brew', 'python3', 'jq'): + self._write_executable(forbidden, 'exit 99\n') + + def environment(self, home=None, state=None): + selected_home = home or self.home + selected_state = state or self.state + return { + **os.environ, + 'HOME': str(selected_home), + 'XDG_STATE_HOME': str(selected_state), + 'TMPDIR': str(self.tmp), + 'PATH': f'{self.commands}:/usr/bin:/bin:/usr/sbin:/sbin', + 'ONE_BITE_BATTERY_PMSET_BIN': str(self.pmset), + 'ONE_BITE_BATTERY_SYSTEM_PROFILER_BIN': str(self.profiler), + 'ONE_BITE_BATTERY_IOREG_BIN': str(self.ioreg), + 'ONE_BITE_BATTERY_DATE_BIN': str(self.date), + 'BATTERY_CALLS': str(self.calls), + 'BATTERY_LIVE_FIXTURE': str(self.live), + 'BATTERY_LOG_FIXTURE': str(self.log_fixture), + } + + def run_tool(self, *arguments, check=True, env=None, entry=SCRIPT): + result = subprocess.run( + ['/bin/bash', '--noprofile', '--norc', str(entry), *arguments], + env=env or self.environment(), text=True, capture_output=True, timeout=30, + ) + if check and result.returncode != 0: + self.fail(f'{arguments} returned {result.returncode}:\n{result.stdout}\n{result.stderr}') + return result + + def assert_private_sentinels_absent(self, text): + for sentinel in PRIVATE_SENTINELS: + self.assertNotIn(sentinel, text) + + def test_json_uses_allowlisted_numeric_fields_and_reconstructs_intervals(self): + result = self.run_tool('--json') + report = json.loads(result.stdout) + self.assertEqual(report['schema_version'], 1) + self.assertEqual(report['status'], 'ok') + self.assertTrue(report['complete']) + self.assertEqual( + report['battery'], + { + 'present': True, + 'charge_percent': 50, + 'power_source': 'battery', + 'state': 'discharging', + 'cycle_count': 12, + 'maximum_capacity_percent': 87, + 'maximum_capacity_mah': 5000, + 'design_capacity_mah': 6000, + 'health': 'normal', + 'voltage_mv': 12000, + 'current_ma': -1410, + }, + ) + history = report['history'] + self.assertEqual(history['duration_basis'], 'wall_clock') + self.assertTrue(history['may_include_sleep']) + self.assertFalse(history['is_lifetime']) + self.assertEqual(history['retained_event_count'], 5) + self.assertEqual(history['interval_count'], 5) + intervals = history['intervals'] + self.assertIn('Sleep Entering Sleep', self.log_fixture.read_text()) + self.assertEqual( + [(item['source'], item['start_percent'], item['end_percent'], + item['duration_seconds'], item['complete']) for item in intervals], + [ + ('battery', 9, 8, 1800, True), + ('ac', 8, 40, 1800, True), + ('battery', 40, 40, 0, True), + ('ac', 40, 100, 3600, True), + ('battery', 100, 50, 10800, False), + ], + ) + self.assertEqual(report['warning_codes'], []) + self.assert_private_sentinels_absent(result.stdout + result.stderr) + + cache_root = self.state / '1bite/battery' + cache = cache_root / 'history-v1.tsv' + self.assertEqual(stat.S_IMODE(cache_root.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(cache.stat().st_mode), 0o600) + cached = cache.read_text() + self.assertTrue(cached.startswith('one-bite-battery-history\t1\n')) + self.assert_private_sentinels_absent(cached) + self.assertFalse(list(cache_root.glob('.history-v1.tsv.*'))) + before = cache.read_bytes() + self.run_tool('--json') + self.assertEqual(cache.read_bytes(), before) + + calls = self.calls.read_text() + self.assertIn('pmset:-g batt', calls) + self.assertIn('pmset:-g log', calls) + self.assertIn('system_profiler:-detailLevel mini -timeout 15 SPPowerDataType', calls) + self.assertIn('ioreg:-r -c AppleSmartBattery', calls) + expected_calls = { + 'pmset:-g batt', + 'pmset:-g log', + 'system_profiler:-detailLevel mini -timeout 15 SPPowerDataType', + 'ioreg:-r -c AppleSmartBattery', + } + self.assertEqual(set(calls.splitlines()), expected_calls) + for call in expected_calls: + self.assertEqual(calls.splitlines().count(call), 2) + + def test_versioned_json_contract_and_bash_32_boundary_are_explicit(self): + report = json.loads(self.run_tool('--json', '--no-cache').stdout) + schema = json.loads((ROOT / 'docs/battery.schema.json').read_text()) + self.assertEqual(set(report), set(schema['required'])) + self.assertEqual(set(report['battery']), set(schema['$defs']['battery']['required'])) + self.assertEqual(set(report['history']), set(schema['$defs']['history']['required'])) + self.assertEqual( + set(report['history']['intervals'][0]), + set(schema['$defs']['interval']['required']), + ) + self.assertIsInstance(report['schema_version'], int) + self.assertIsInstance(report['complete'], bool) + self.assertIn(report['status'], ('ok', 'partial', 'no_battery', 'unavailable')) + self.assertIsInstance(report['battery']['present'], bool) + self.assertIn(report['battery']['power_source'], ('ac', 'battery', 'unknown', None)) + self.assertIn(report['battery']['state'], + ('charging', 'charged', 'discharging', 'not_charging', 'unknown', None)) + self.assertTrue(0 <= report['battery']['charge_percent'] <= 100) + self.assertTrue(all(isinstance(value, int) for value in ( + report['history']['retained_event_count'], report['history']['interval_count']))) + for interval in report['history']['intervals']: + self.assertIn(interval['source'], ('ac', 'battery')) + self.assertTrue(0 <= interval['start_percent'] <= 100) + self.assertTrue(0 <= interval['end_percent'] <= 100) + self.assertTrue(-100 <= interval['delta_percent'] <= 100) + self.assertGreaterEqual(interval['duration_seconds'], 0) + self.assertIsInstance(interval['complete'], bool) + source = SCRIPT.read_text() + version = (ROOT / 'VERSION').read_text().strip() + self.assertIn(f'BATTERY_TOOL_VERSION={version}\n', source) + self.assertTrue(source.startswith('#!/bin/bash\n')) + for unsupported in ('declare -A', 'mapfile ', 'readarray ', '${value,,}'): + self.assertNotIn(unsupported, source) + + def test_dashboard_json_tsv_shell_and_field_interfaces_are_consistent(self): + dashboard = self.run_tool('--no-cache').stdout + self.assertIn('50% (battery, discharging)', dashboard) + self.assertIn('equivalent full cycles', dashboard) + self.assertIn('not the number of times power was connected', dashboard) + self.assertIn('wall-clock time and may include sleep', dashboard) + self.assertIn('not lifetime charge/discharge totals', dashboard) + + report = json.loads(self.run_tool('--json', '--no-cache').stdout) + tsv = self.run_tool('--tsv', '--no-cache').stdout + self.assertTrue(tsv.startswith('schema_version\tsection\tindex\tkey\tvalue\tunit\n')) + self.assertIn('1\tbattery\t-\tcharge_percent\t50\tpercent\n', tsv) + self.assertIn('1\tinterval\t2\tduration_seconds\t0\tseconds\n', tsv) + + shell_output = self.run_tool('--shell', '--no-cache').stdout + shell_file = self.root / 'battery-output.sh' + shell_file.write_text(shell_output) + sourced = subprocess.run( + ['/bin/bash', '--noprofile', '--norc', '-c', + 'source "$1"; printf "%s|%s|%s\\n" "$ONE_BITE_BATTERY_CHARGE_PERCENT" ' + '"$ONE_BITE_BATTERY_CYCLE_COUNT" "$ONE_BITE_BATTERY_INTERVAL_2_DURATION_SECONDS"', + 'bash', str(shell_file)], + text=True, capture_output=True, check=True, + ) + self.assertEqual(sourced.stdout, '50|12|0\n') + + self.live.write_text("Now drawing from 'AC Power'\n") + empty_shell_output = self.run_tool('--shell', '--no-cache').stdout + shell_file.write_text(shell_output + empty_shell_output) + cleared = subprocess.run( + ['/bin/bash', '--noprofile', '--norc', '-c', + 'source "$1"; printf "%s|%s\\n" "$ONE_BITE_BATTERY_INTERVAL_COUNT" ' + '"${ONE_BITE_BATTERY_INTERVAL_0_SOURCE-unset}"', 'bash', str(shell_file)], + text=True, capture_output=True, check=True, + ) + self.assertEqual(cleared.stdout, '0|unset\n') + self.live.write_text( + "Now drawing from 'Battery Power'\n" + " -InternalBattery-0\t050%; discharging; 2:00 remaining present: true\n" + ) + field = self.run_tool('--field', 'battery.charge_percent', '--no-cache') + self.assertEqual(field.stdout, f"{report['battery']['charge_percent']}\n") + unknown = self.run_tool('--field', 'battery.serial_number', '--no-cache', check=False) + self.assertEqual(unknown.returncode, 2) + self.assertNotIn('SECRET', unknown.stdout + unknown.stderr) + + for output in (dashboard, tsv, shell_output): + self.assert_private_sentinels_absent(output) + + def test_startup_summary_is_single_line_stateless_and_handles_absent_data(self): + summary = self.run_tool('--summary') + self.assertEqual( + summary.stdout, + 'Battery: charge 50%; battery power, discharging; maximum capacity 87%; ' + 'cycle count 12 equivalent full cycles.\n', + ) + self.assertEqual(summary.stderr, '') + self.assertEqual(summary.stdout.count('\n'), 1) + self.assertEqual( + self.calls.read_text().splitlines(), + [ + 'pmset:-g batt', + 'system_profiler:-detailLevel mini -timeout 15 SPPowerDataType', + 'ioreg:-r -c AppleSmartBattery', + ], + ) + self.assertNotIn('pmset:-g log', self.calls.read_text()) + self.assertFalse(self.state.exists()) + self.assert_private_sentinels_absent(summary.stdout + summary.stderr) + + self.calls.unlink() + self.live.write_text('') + unavailable = self.run_tool('--summary', check=False) + self.assertEqual(unavailable.returncode, 1) + self.assertEqual(unavailable.stdout, 'Battery: unavailable; setup will continue.\n') + self.assertEqual(self.calls.read_text().splitlines(), ['pmset:-g batt']) + self.assertFalse(self.state.exists()) + + self.calls.unlink() + self.live.write_text("Now drawing from 'AC Power'\n") + no_battery = self.run_tool('--summary') + self.assertEqual(no_battery.stdout, 'Battery: not present.\n') + self.assertEqual(self.calls.read_text().splitlines(), ['pmset:-g batt']) + self.assertFalse(self.state.exists()) + + def test_uppercase_log_spacing_and_leading_zero_percent_are_numeric(self): + self.log_fixture = FIXTURES / 'pmset-log-batt-uppercase.txt' + self.live.write_text( + "Now drawing from 'AC Power'\n" + " -InternalBattery-0\t010%; charging; 0:20 remaining present: true\n" + ) + result = self.run_tool('--json', '--no-cache') + report = json.loads(result.stdout) + intervals = report['history']['intervals'] + self.assertEqual( + [(item['source'], item['start_percent'], item['end_percent'], item['duration_seconds']) + for item in intervals[:2]], + [('ac', 5, 5, 900), ('battery', 5, 4, 2700)], + ) + self.assertIsInstance(report['battery']['charge_percent'], int) + self.assertEqual(report['battery']['charge_percent'], 10) + + def test_unparseable_history_is_partial_without_exposing_raw_lines(self): + malformed = self.root / 'malformed-history.txt' + malformed.write_text( + 'private-host.example SECRET-SERIAL-123 Using BATT with no date or charge\n' + ) + self.log_fixture = malformed + report = json.loads(self.run_tool('--json', '--no-cache').stdout) + self.assertEqual(report['status'], 'partial') + self.assertFalse(report['complete']) + self.assertIn('history_parse_failed', report['warning_codes']) + self.assertEqual(report['history']['retained_event_count'], 1) + self.assertEqual(report['history']['interval_count'], 1) + self.assert_private_sentinels_absent(json.dumps(report)) + + malformed.write_text( + '2026-09-01 08:00:00 +0000 Wake Using Batt (Charge: 50%)\n' + 'new-format private-host.example Using AC (Charge: 51%) SECRET-SERIAL-123\n' + ) + report = json.loads(self.run_tool('--json', '--no-cache').stdout) + self.assertEqual(report['status'], 'partial') + self.assertIn('history_parse_partial', report['warning_codes']) + self.assert_private_sentinels_absent(json.dumps(report)) + + def test_unavailable_live_probe_and_unsafe_state_still_have_predictable_results(self): + self.live.write_text('') + unavailable = self.run_tool('--json', check=False) + self.assertEqual(unavailable.returncode, 1) + report = json.loads(unavailable.stdout) + self.assertEqual(report['status'], 'unavailable') + self.assertFalse(report['complete']) + self.assertIsNone(report['battery']['present']) + self.assertIn('pmset_batt_incomplete', report['warning_codes']) + self.assertFalse(self.state.exists()) + + dashboard = self.run_tool('--no-cache', check=False) + self.assertEqual(dashboard.returncode, 1) + self.assertIn('Battery data: unavailable', dashboard.stdout) + self.assertNotIn('Battery: not present', dashboard.stdout) + tsv = self.run_tool('--tsv', '--no-cache', check=False) + self.assertIn('1\tbattery\t-\tpresent\tnull\tboolean\n', tsv.stdout) + + self.live.write_text("Now drawing from 'Battery Power'\n") + incomplete = self.run_tool('--json', '--no-cache', check=False) + self.assertEqual(incomplete.returncode, 1) + self.assertEqual(json.loads(incomplete.stdout)['battery']['present'], None) + + self.live.write_text( + "Now drawing from 'Battery Power'\n" + " -InternalBattery-0\t50%; discharging; 2:00 remaining present: true\n" + ) + self.state.mkdir() + blocker = self.state / '1bite' + blocker.write_text('preserve this file\n') + result = self.run_tool('--json') + report = json.loads(result.stdout) + self.assertEqual(report['status'], 'ok') + self.assertIn('state_path_unsafe', report['warning_codes']) + self.assertEqual(blocker.read_text(), 'preserve this file\n') + + def test_unsigned_current_wrap_modulus_is_rejected_instead_of_becoming_zero(self): + source = self.ioreg.read_text().replace( + '18446744073709550206', '18446744073709551616' + ) + self.ioreg.write_text(source) + self.ioreg.chmod(0o755) + report = json.loads(self.run_tool('--json', '--no-cache').stdout) + self.assertIsNone(report['battery']['current_ma']) + self.assertEqual(report['status'], 'partial') + + def test_unknown_field_fails_before_probes_or_cache_writes(self): + result = self.run_tool('--field', 'battery.serial_number', check=False) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, '') + self.assertFalse(self.calls.exists()) + self.assertFalse(self.state.exists()) + + def test_no_battery_is_success_and_does_not_probe_or_cache_history(self): + self.live.write_text("Now drawing from 'AC Power'\n") + result = self.run_tool('--json') + report = json.loads(result.stdout) + self.assertEqual(result.returncode, 0) + self.assertEqual(report['status'], 'no_battery') + self.assertTrue(report['complete']) + self.assertFalse(report['battery']['present']) + self.assertEqual(report['history']['intervals'], []) + self.assertFalse(self.state.exists()) + calls = self.calls.read_text().splitlines() + self.assertEqual(calls, ['pmset:-g batt']) + missing = self.run_tool('--field', 'battery.cycle_count', '--no-cache', check=False) + self.assertEqual(missing.returncode, 3) + self.assertEqual(missing.stdout, '') + + def test_corrupt_unknown_and_symlink_caches_have_safe_boundaries(self): + cache_root = self.state / '1bite/battery' + cache_root.mkdir(parents=True) + cache = cache_root / 'history-v1.tsv' + + cache.write_text( + 'one-bite-battery-history\t1\n' + 'event\t1788249600\tSECRET-SERIAL-123"\tbattery\t50\t0\n' + ) + result = self.run_tool('--json') + self.assertIn('cache_corrupt', json.loads(result.stdout)['warning_codes']) + self.assertNotIn('SECRET-SERIAL-123', cache.read_text()) + + cache.write_text( + 'one-bite-battery-history\t1\n' + 'event\t1788249600\t2026-09-01T08:00:00Z\tbattery\t050\t0\n' + ) + result = self.run_tool('--json') + report = json.loads(result.stdout) + self.assertIn('cache_corrupt', report['warning_codes']) + self.assertNotIn('\t050\t', cache.read_text()) + + unknown = b'user-owned unknown cache\nSECRET-SERIAL-123\n' + cache.write_bytes(unknown) + result = self.run_tool('--json') + self.assertIn('cache_unknown', json.loads(result.stdout)['warning_codes']) + self.assertEqual(cache.read_bytes(), unknown) + self.assert_private_sentinels_absent(result.stdout + result.stderr) + + cache.unlink() + target = self.root / 'cache-target' + target.write_text('do not replace\n') + cache.symlink_to(target) + result = self.run_tool('--json') + self.assertIn('cache_symlink', json.loads(result.stdout)['warning_codes']) + self.assertEqual(target.read_text(), 'do not replace\n') + self.assertTrue(cache.is_symlink()) + + def test_invalid_optional_metrics_and_collection_time_fail_predictably(self): + source = self.profiler.read_text().replace('Maximum Capacity: 087%', + 'Maximum Capacity: 101%') + self.profiler.write_text(source) + self.profiler.chmod(0o755) + report = json.loads(self.run_tool('--json', '--no-cache').stdout) + self.assertEqual(report['status'], 'partial') + self.assertIsNone(report['battery']['maximum_capacity_percent']) + self.assertIn('metrics_incomplete', report['warning_codes']) + + self.date.write_text( + '#!/bin/bash\nset -eu\n' + 'printf "1788267600 2026-09-01T13:00:01Z\\n"\n' + ) + self.date.chmod(0o755) + failed = self.run_tool('--json', '--no-cache', check=False) + self.assertEqual(failed.returncode, 1) + self.assertEqual(failed.stdout, '') + self.assertIn('inconsistent timestamp', failed.stderr) + + def test_main_dispatch_bypasses_install_session_and_lock(self): + result = self.run_tool('--battery', '--json', '--no-cache', entry=ROOT / '1bite') + self.assertEqual(json.loads(result.stdout)['battery']['charge_percent'], 50) + self.assertFalse(list(self.state.rglob('session.lock'))) + self.assertFalse(list(self.state.rglob('environment.json'))) + self.assertFalse(list(self.state.rglob('result.json'))) + + installed = self.run_tool('--install-battery', entry=ROOT / '1bite') + self.assertIn('Installed One Bite battery tool', installed.stdout) + self.assertTrue((self.home / '.local/bin/1bite-battery').is_file()) + self.assertFalse(list(self.state.rglob('session.lock'))) + self.assertFalse(list(self.state.rglob('result.json'))) + + def test_explicit_tool_install_skips_current_and_updates_only_owned_bytes(self): + result = self.run_tool('--install-tool') + target = self.home / '.local/bin/1bite-battery' + receipt = self.state / '1bite/battery/tool-receipt-v1.tsv' + self.assertIn('Installed One Bite battery tool', result.stdout) + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE(receipt.stat().st_mode), 0o600) + original_mtime = target.stat().st_mtime_ns + result = self.run_tool('--install-tool') + self.assertIn('already current', result.stdout) + self.assertEqual(target.stat().st_mtime_ns, original_mtime) + + current_digest = hashlib.sha256(target.read_bytes()).hexdigest() + current_version = (ROOT / 'VERSION').read_text().strip() + receipt.unlink() + pending = self.state / '1bite/battery/tool-pending-v1.tsv' + pending.write_text( + f'one-bite-battery-tool-pending\t1\nversion\t{current_version}\n' + f'sha256\t{current_digest}\n' + ) + pending.chmod(0o600) + result = self.run_tool('--install-tool') + self.assertIn('Recovered ownership', result.stdout) + self.assertTrue(receipt.is_file()) + self.assertFalse(pending.exists()) + + target.write_bytes(target.read_bytes() + b'\n# older managed release\n') + target.chmod(0o755) + old_bytes = target.read_bytes() + old_digest = hashlib.sha256(old_bytes).hexdigest() + receipt.write_text( + f'one-bite-battery-tool\t1\nversion\t0.0.6\nsha256\t{old_digest}\n' + ) + receipt.chmod(0o600) + result = self.run_tool('--install-tool') + self.assertIn('Run with --update', result.stdout) + self.assertEqual(target.read_bytes(), old_bytes) + result = self.run_tool('--install-tool', '--update') + self.assertIn('Installed One Bite battery tool', result.stdout) + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + backups = list((self.state / '1bite/battery').glob('1bite-battery.backup-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_bytes(), old_bytes) + self.assertEqual(stat.S_IMODE(backups[0].stat().st_mode), 0o600) + + def test_explicit_tool_install_preserves_unknown_modified_and_symlink_targets(self): + other_home = self.root / 'unknown-home' + other_state = self.root / 'unknown-state' + target = other_home / '.local/bin/1bite-battery' + target.parent.mkdir(parents=True) + target.write_bytes(SCRIPT.read_bytes()) + target.chmod(0o755) + env = self.environment(other_home, other_state) + result = self.run_tool('--install-tool', '--update', check=False, env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + + target.write_text('#!/bin/bash\necho user tool\n') + target.chmod(0o755) + result = self.run_tool('--install-tool', '--update', check=False, env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(target.read_text(), '#!/bin/bash\necho user tool\n') + + target.unlink() + link_target = self.root / 'user-tool' + link_target.write_text('user bytes\n') + target.symlink_to(link_target) + result = self.run_tool('--install-tool', '--update', check=False, env=env) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(target.is_symlink()) + self.assertEqual(link_target.read_text(), 'user bytes\n') + + def test_tool_install_preserves_unknown_receipt_and_repairs_damage_only_on_update(self): + receipt_root = self.state / '1bite/battery' + receipt_root.mkdir(parents=True) + receipt = receipt_root / 'tool-receipt-v1.tsv' + receipt.write_text('USER-OWNED\nSECRET-SERIAL-123\n') + result = self.run_tool('--install-tool', check=False) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(receipt.read_text(), 'USER-OWNED\nSECRET-SERIAL-123\n') + self.assertFalse((self.home / '.local/bin/1bite-battery').exists()) + + receipt.unlink() + self.run_tool('--install-tool') + target = self.home / '.local/bin/1bite-battery' + target.chmod(0o644) + result = self.run_tool('--install-tool', check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn('damaged', result.stderr) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o644) + self.run_tool('--install-tool', '--update') + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o755) + + receipt.chmod(0o644) + refused = self.run_tool('--install-tool', '--update', check=False) + self.assertNotEqual(refused.returncode, 0) + self.assertEqual(stat.S_IMODE(receipt.stat().st_mode), 0o644) + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + + def test_tool_install_recovers_each_safe_pending_state(self): + private_root = self.state / '1bite/battery' + private_root.mkdir(parents=True) + pending = private_root / 'tool-pending-v1.tsv' + current_digest = hashlib.sha256(SCRIPT.read_bytes()).hexdigest() + current_version = (ROOT / 'VERSION').read_text().strip() + pending.write_text( + f'one-bite-battery-tool-pending\t1\nversion\t{current_version}\n' + f'sha256\t{current_digest}\n' + ) + pending.chmod(0o600) + self.run_tool('--install-tool') + target = self.home / '.local/bin/1bite-battery' + receipt = private_root / 'tool-receipt-v1.tsv' + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + self.assertFalse(pending.exists()) + + old_bytes = SCRIPT.read_bytes() + b'\n# previous managed bytes\n' + old_digest = hashlib.sha256(old_bytes).hexdigest() + target.write_bytes(old_bytes) + target.chmod(0o755) + receipt.write_text( + f'one-bite-battery-tool\t1\nversion\t0.0.6\nsha256\t{old_digest}\n' + ) + receipt.chmod(0o600) + pending.write_text( + f'one-bite-battery-tool-pending\t1\nversion\t{current_version}\n' + f'sha256\t{current_digest}\n' + ) + pending.chmod(0o600) + refused = self.run_tool('--install-tool', check=False) + self.assertNotEqual(refused.returncode, 0) + self.assertEqual(target.read_bytes(), old_bytes) + self.assertTrue(pending.exists()) + self.run_tool('--install-tool', '--update') + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + self.assertFalse(pending.exists()) + + def test_tool_install_lock_rejects_a_concurrent_operation(self): + private_root = self.state / '1bite/battery' + private_root.mkdir(parents=True) + lock_path = private_root / 'tool.lock' + with lock_path.open('a') as lock_file: + lock_path.chmod(0o600) + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = self.run_tool('--install-tool', check=False) + self.assertEqual(result.returncode, 75, result.stderr) + self.assertIn('another battery-tool install', result.stderr) + self.assertFalse((self.home / '.local/bin/1bite-battery').exists()) + + installed = self.run_tool('--install-tool') + self.assertIn('Installed One Bite battery tool', installed.stdout) + self.assertTrue(lock_path.is_file()) + self.assertEqual(stat.S_IMODE(lock_path.stat().st_mode), 0o600) + + fifo_home = self.root / 'fifo-home' + fifo_state = self.root / 'fifo-state' + fifo_root = fifo_state / '1bite/battery' + fifo_root.mkdir(parents=True) + fifo_lock = fifo_root / 'tool.lock' + os.mkfifo(fifo_lock, 0o600) + refused = self.run_tool('--install-tool', check=False, + env=self.environment(fifo_home, fifo_state)) + self.assertEqual(refused.returncode, 1) + self.assertTrue(stat.S_ISFIFO(fifo_lock.lstat().st_mode)) + + def test_tool_install_publish_race_and_noncanonical_home_preserve_user_paths(self): + race_target = self.home / '.local/bin/1bite-battery' + race_marker = self.root / 'race-marker' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + 'if [[ "$last" == "$RACE_TARGET" && ! -e "$RACE_MARKER" ]]; then\n' + ' mkdir -p "${RACE_TARGET%/*}"\n' + ' printf "#!/bin/bash\\necho user-race\\n" >"$RACE_TARGET"\n' + ' chmod 755 "$RACE_TARGET"\n' + ' : >"$RACE_MARKER"\n' + 'fi\n' + 'exec /bin/mv "$@"\n', + ) + env = self.environment() + env.update(RACE_TARGET=str(race_target), RACE_MARKER=str(race_marker)) + result = self.run_tool('--install-tool', check=False, env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(race_target.read_text(), '#!/bin/bash\necho user-race\n') + self.assertFalse((self.state / '1bite/battery/tool-receipt-v1.tsv').exists()) + + swap_home = self.root / 'swap-home' + swap_state = self.root / 'swap-state' + swap_bin = swap_home / '.local/bin' + swapped_bin = swap_home / '.local/bin-before-swap' + outside = self.root / 'outside-bin' + outside.mkdir() + outside_target = outside / '1bite-battery' + outside_target.write_bytes(SCRIPT.read_bytes()) + outside_target.chmod(0o755) + swap_marker = self.root / 'swap-marker' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + 'if [[ "$last" == "$SWAP_TARGET" && ! -e "$SWAP_MARKER" ]]; then\n' + ' /bin/mv "$SWAP_BIN" "$SWAPPED_BIN"\n' + ' ln -s "$OUTSIDE_BIN" "$SWAP_BIN"\n' + ' : >"$SWAP_MARKER"\n' + 'fi\n' + 'exec /bin/mv "$@"\n', + ) + swap_env = self.environment(swap_home, swap_state) + swap_env.update( + SWAP_TARGET=str(swap_bin / '1bite-battery'), + SWAP_BIN=str(swap_bin), + SWAPPED_BIN=str(swapped_bin), + OUTSIDE_BIN=str(outside), + SWAP_MARKER=str(swap_marker), + ) + swapped = self.run_tool('--install-tool', check=False, env=swap_env) + self.assertNotEqual(swapped.returncode, 0) + self.assertEqual(outside_target.read_bytes(), SCRIPT.read_bytes()) + self.assertFalse((swap_state / '1bite/battery/tool-receipt-v1.tsv').exists()) + + base = self.root / 'noncanonical' + real_home = base / 'real-home' + real_home.mkdir(parents=True) + (base / 'link-home').symlink_to(real_home, target_is_directory=True) + odd_home = base / 'missing/../link-home' + odd_state = self.root / 'odd-state' + refused = self.run_tool('--install-tool', check=False, + env=self.environment(odd_home, odd_state)) + self.assertNotEqual(refused.returncode, 0) + self.assertFalse((base / 'missing').exists()) + self.assertFalse((real_home / '.local/bin/1bite-battery').exists()) + + def test_pending_recovery_rejects_bin_parent_replacement_after_receipt_publish(self): + bin_parent = self.home / '.local/bin' + target = bin_parent / '1bite-battery' + bin_parent.mkdir(parents=True) + target.write_bytes(SCRIPT.read_bytes()) + target.chmod(0o755) + + private_root = self.state / '1bite/battery' + private_root.mkdir(parents=True) + pending = private_root / 'tool-pending-v1.tsv' + digest = hashlib.sha256(target.read_bytes()).hexdigest() + pending.write_text( + 'one-bite-battery-tool-pending\t1\nversion\t0.0.6\n' + f'sha256\t{digest}\n' + ) + pending.chmod(0o600) + + external_bin = self.root / 'external-user-bin' + external_bin.mkdir() + external_target = external_bin / '1bite-battery' + external_bytes = b'#!/bin/bash\necho external-user\n' + external_target.write_bytes(external_bytes) + external_target.chmod(0o755) + saved_bin = self.root / 'managed-bin-before-race' + receipt = private_root / 'tool-receipt-v1.tsv' + marker = self.root / 'receipt-parent-race' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + 'status=0\n' + '/bin/mv "$@" || status=$?\n' + 'if [[ "$status" -eq 0 && "$last" == "$RACE_RECEIPT" && ' + '! -e "$RACE_MARKER" ]]; then\n' + ' /bin/mv "$RACE_BIN_PARENT" "$RACE_SAVED_BIN"\n' + ' /bin/ln -s "$RACE_EXTERNAL_BIN" "$RACE_BIN_PARENT"\n' + ' : >"$RACE_MARKER"\n' + 'fi\n' + 'exit "$status"\n', + ) + env = self.environment() + env.update( + RACE_RECEIPT=str(receipt), + RACE_MARKER=str(marker), + RACE_BIN_PARENT=str(bin_parent), + RACE_SAVED_BIN=str(saved_bin), + RACE_EXTERNAL_BIN=str(external_bin), + ) + result = self.run_tool('--install-tool', check=False, env=env) + + self.assertNotEqual(result.returncode, 0) + self.assertTrue(pending.is_file()) + self.assertEqual(external_target.read_bytes(), external_bytes) + self.assertNotIn('Recovered ownership', result.stdout) + self.assertNotIn('already current', result.stdout) + + def test_receipt_publish_permission_race_keeps_pending_and_never_reports_success(self): + target = self.home / '.local/bin/1bite-battery' + receipt = self.state / '1bite/battery/tool-receipt-v1.tsv' + pending = self.state / '1bite/battery/tool-pending-v1.tsv' + marker = self.root / 'receipt-permission-race' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + 'status=0\n' + '/bin/mv "$@" || status=$?\n' + 'if [[ "$status" -eq 0 && "$last" == "$RACE_RECEIPT" && ' + '! -e "$RACE_MARKER" ]]; then\n' + ' chmod 0644 "$RACE_TARGET"\n' + ' : >"$RACE_MARKER"\n' + 'fi\n' + 'exit "$status"\n', + ) + env = self.environment() + env.update( + RACE_RECEIPT=str(receipt), + RACE_MARKER=str(marker), + RACE_TARGET=str(target), + ) + result = self.run_tool('--install-tool', check=False, env=env) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o644) + self.assertTrue(pending.is_file()) + self.assertNotIn('Installed One Bite battery tool', result.stdout) + self.assertNotIn('Recovered ownership', result.stdout) + self.assertNotIn('already current', result.stdout) + + def test_update_rejects_traversal_in_backup_timestamp_before_creating_a_backup(self): + self.run_tool('--install-tool') + target = self.home / '.local/bin/1bite-battery' + private_root = self.state / '1bite/battery' + receipt = private_root / 'tool-receipt-v1.tsv' + pending = private_root / 'tool-pending-v1.tsv' + + old_bytes = SCRIPT.read_bytes() + b'\n# previous managed bytes\n' + old_digest = hashlib.sha256(old_bytes).hexdigest() + target.write_bytes(old_bytes) + target.chmod(0o755) + receipt.write_text( + f'one-bite-battery-tool\t1\nversion\t0.0.6\nsha256\t{old_digest}\n' + ) + receipt.chmod(0o600) + target_before = target.read_bytes() + receipt_before = receipt.read_bytes() + target_mtime = target.stat().st_mtime_ns + receipt_mtime = receipt.stat().st_mtime_ns + + traversal_parent = private_root / '.1bite-battery.backup-slot' + traversal_parent.mkdir() + self.date.write_text( + '#!/bin/bash\nset -eu\n' + 'case "$*" in\n' + ' "-u +%Y%m%dT%H%M%SZ") printf "slot/../../escape\\n" ;;\n' + ' *) exit 64 ;;\n' + 'esac\n' + ) + self.date.chmod(0o755) + + result = self.run_tool('--install-tool', '--update', check=False) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(target.read_bytes(), target_before) + self.assertEqual(receipt.read_bytes(), receipt_before) + self.assertEqual(target.stat().st_mtime_ns, target_mtime) + self.assertEqual(receipt.stat().st_mtime_ns, receipt_mtime) + self.assertFalse(pending.exists()) + self.assertEqual(list(traversal_parent.iterdir()), []) + self.assertFalse(list((self.state / '1bite').glob('escape.*'))) + self.assertFalse(list(private_root.glob('escape.*'))) + self.assertFalse([ + path for path in private_root.glob('*backup*') + if path.is_file() + ]) + self.assertNotIn('Installed One Bite battery tool', result.stdout) + + def test_tool_install_parent_swap_cannot_delete_or_claim_unknown_paths(self): + current_digest = hashlib.sha256(SCRIPT.read_bytes()).hexdigest() + current_version = (ROOT / 'VERSION').read_text().strip() + + state_home = self.root / 'state-swap-home' + state_root = self.root / 'state-swap-state/1bite/battery' + moved_state = self.root / 'state-before-swap' + outside_state = self.root / 'outside-state' + outside_state.mkdir() + outside_pending = outside_state / 'tool-pending-v1.tsv' + outside_pending.write_text( + f'one-bite-battery-tool-pending\t1\nversion\t{current_version}\n' + f'sha256\t{current_digest}\n' + ) + outside_pending.chmod(0o600) + state_marker = self.root / 'state-swap-marker' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + 'case "$last" in\n' + ' "$STATE_ROOT"/tool-pending-v1.tsv.removed.*)\n' + ' if [[ ! -e "$STATE_MARKER" ]]; then\n' + ' /bin/mv "$STATE_ROOT" "$MOVED_STATE"\n' + ' ln -s "$OUTSIDE_STATE" "$STATE_ROOT"\n' + ' : >"$STATE_MARKER"\n' + ' fi\n' + ' ;;\n' + 'esac\n' + 'exec /bin/mv "$@"\n', + ) + state_env = self.environment(state_home, self.root / 'state-swap-state') + state_env.update( + STATE_ROOT=str(state_root), MOVED_STATE=str(moved_state), + OUTSIDE_STATE=str(outside_state), STATE_MARKER=str(state_marker), + ) + state_result = self.run_tool('--install-tool', check=False, env=state_env) + self.assertNotEqual(state_result.returncode, 0) + self.assertEqual(outside_pending.read_text(), + f'one-bite-battery-tool-pending\t1\nversion\t{current_version}\n' + f'sha256\t{current_digest}\n') + self.assertNotIn('Installed One Bite battery tool', state_result.stdout) + + bin_home = self.root / 'late-bin-home' + bin_state = self.root / 'late-bin-state' + bin_dir = bin_home / '.local/bin' + moved_bin = bin_home / '.local/bin-before-late-swap' + outside_bin = self.root / 'late-outside-bin' + outside_bin.mkdir() + outside_target = outside_bin / '1bite-battery' + outside_target.write_text('#!/bin/bash\necho outside-user-tool\n') + outside_target.chmod(0o755) + bin_marker = self.root / 'late-bin-marker' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + '/bin/mv "$@"\n' + 'if [[ "$last" == "$RECEIPT" && ! -e "$BIN_MARKER" ]]; then\n' + ' /bin/mv "$BIN_DIR" "$MOVED_BIN"\n' + ' ln -s "$OUTSIDE_BIN" "$BIN_DIR"\n' + ' : >"$BIN_MARKER"\n' + 'fi\n', + ) + bin_env = self.environment(bin_home, bin_state) + bin_env.update( + RECEIPT=str(bin_state / '1bite/battery/tool-receipt-v1.tsv'), + BIN_DIR=str(bin_dir), MOVED_BIN=str(moved_bin), OUTSIDE_BIN=str(outside_bin), + BIN_MARKER=str(bin_marker), + ) + bin_result = self.run_tool('--install-tool', check=False, env=bin_env) + self.assertNotEqual(bin_result.returncode, 0) + self.assertEqual(outside_target.read_text(), '#!/bin/bash\necho outside-user-tool\n') + self.assertTrue((bin_state / '1bite/battery/tool-pending-v1.tsv').is_file()) + self.assertNotIn('Installed One Bite battery tool', bin_result.stdout) + + recovery_home = self.root / 'recovery-bin-home' + recovery_state = self.root / 'recovery-bin-state' + recovery_bin = recovery_home / '.local/bin' + recovery_bin.mkdir(parents=True) + recovery_target = recovery_bin / '1bite-battery' + recovery_target.write_bytes(SCRIPT.read_bytes()) + recovery_target.chmod(0o755) + recovery_root = recovery_state / '1bite/battery' + recovery_root.mkdir(parents=True) + recovery_root.chmod(0o700) + recovery_pending = recovery_root / 'tool-pending-v1.tsv' + recovery_pending.write_text( + f'one-bite-battery-tool-pending\t1\nversion\t{current_version}\n' + f'sha256\t{current_digest}\n' + ) + recovery_pending.chmod(0o600) + recovery_moved_bin = recovery_home / '.local/bin-before-recovery-swap' + recovery_outside = self.root / 'recovery-outside-bin' + recovery_outside.mkdir() + recovery_outside_target = recovery_outside / '1bite-battery' + recovery_outside_target.write_text('#!/bin/bash\necho recovery-user-tool\n') + recovery_outside_target.chmod(0o755) + recovery_marker = self.root / 'recovery-bin-marker' + self._write_executable( + 'mv', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + '/bin/mv "$@"\n' + 'if [[ "$last" == "$RECOVERY_RECEIPT" && ! -e "$RECOVERY_MARKER" ]]; then\n' + ' /bin/mv "$RECOVERY_BIN" "$RECOVERY_MOVED_BIN"\n' + ' ln -s "$RECOVERY_OUTSIDE" "$RECOVERY_BIN"\n' + ' : >"$RECOVERY_MARKER"\n' + 'fi\n', + ) + recovery_env = self.environment(recovery_home, recovery_state) + recovery_env.update( + RECOVERY_RECEIPT=str(recovery_root / 'tool-receipt-v1.tsv'), + RECOVERY_BIN=str(recovery_bin), RECOVERY_MOVED_BIN=str(recovery_moved_bin), + RECOVERY_OUTSIDE=str(recovery_outside), RECOVERY_MARKER=str(recovery_marker), + ) + recovered = self.run_tool('--install-tool', check=False, env=recovery_env) + self.assertNotEqual(recovered.returncode, 0) + self.assertEqual(recovery_outside_target.read_text(), + '#!/bin/bash\necho recovery-user-tool\n') + self.assertTrue(recovery_pending.is_file()) + self.assertNotIn('Recovered ownership', recovered.stdout) + self.assertNotIn('already current', recovered.stdout) + + def test_tool_install_receipt_write_failure_remains_recoverable(self): + self._write_executable( + 'chmod', + 'last=""\n' + 'for value in "$@"; do last=$value; done\n' + 'case "$last" in\n' + ' */.tool-receipt-v1.tsv.*) /bin/chmod 644 "$last"; exit 1 ;;\n' + ' *) exec /bin/chmod "$@" ;;\n' + 'esac\n', + ) + failed = self.run_tool('--install-tool', check=False) + target = self.home / '.local/bin/1bite-battery' + private_root = self.state / '1bite/battery' + receipt = private_root / 'tool-receipt-v1.tsv' + pending = private_root / 'tool-pending-v1.tsv' + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(target.read_bytes(), SCRIPT.read_bytes()) + self.assertTrue(pending.is_file()) + self.assertFalse(receipt.exists()) + + self._write_executable('chmod', 'exec /bin/chmod "$@"\n') + recovered = self.run_tool('--install-tool') + self.assertIn('Recovered ownership', recovered.stdout) + self.assertTrue(receipt.is_file()) + self.assertFalse(pending.exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_kiro_shell.py b/tests/test_kiro_shell.py index f958f77..fda1240 100644 --- a/tests/test_kiro_shell.py +++ b/tests/test_kiro_shell.py @@ -17,6 +17,12 @@ class KiroShellTests(unittest.TestCase): + def test_full_verifier_uses_the_stable_vendor_contract(self): + verifier = (ROOT / 'scripts/verify.sh').read_text() + self.assertIn('scripts/kiro-shell.py\" --verify', verifier) + self.assertNotIn('fig_preexec', verifier) + self.assertNotIn('fig_precmd', verifier) + def test_custom_kiro_home_requires_supported_cli_before_any_writes(self): with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {'KIRO_HOME': directory + '/custom'}): home = Path(directory) @@ -184,7 +190,11 @@ def run(extra=()): self.assertIn('skipped', run().stdout) self.assertEqual(before, {p:p.read_bytes() for p in tracked}) run(['--verify']) - command = '(( $+functions[fig_preexec] && $+functions[fig_precmd] )) && print -r -- "$ONE_BITE_PERSONAL"' + # Vendor releases may implement their post hook without the old + # private fig_preexec/fig_precmd function names. The public + # contract is a healthy integration whose login shell continues + # into the user's existing configuration. + command = 'print -r -- "$ONE_BITE_PERSONAL"' result = subprocess.run(['zsh', '-lic', command], env=env, capture_output=True, text=True, check=True, timeout=15) self.assertEqual(result.stdout.strip(), 'preserved') self.assertEqual(result.stderr, '') diff --git a/tests/test_setup.py b/tests/test_setup.py index 109a669..a5c73b1 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -1453,9 +1453,47 @@ def test_platform_contract(self): for arch, version, uid, accepted in [('arm64', '26.6.2', 501, True), ('arm64', '27.0', 501, True), ('arm64', '15.7', 501, False), ('x86_64', '26.6.2', 501, False), ('arm64', '26.6.2', 0, False)]: - result = self.run_shell(f'uname() {{ echo {arch}; }}; sw_vers() {{ echo {version}; }}; ' - f'id() {{ echo {uid}; }}; check_install_target') + result = self.run_shell( + 'startup_battery_summary() { echo BATTERY-SUMMARY-CALLED; }; ' + f'uname() {{ echo {arch}; }}; sw_vers() {{ echo {version}; }}; ' + f'id() {{ echo {uid}; }}; check_install_target' + ) self.assertEqual(result.returncode == 0, accepted) + self.assertEqual('BATTERY-SUMMARY-CALLED' in result.stdout, accepted) + + def test_startup_battery_summary_is_fail_open_after_target_validation(self): + with tempfile.TemporaryDirectory(prefix='startup-battery-') as directory: + fake_root = Path(directory) + scripts = fake_root / 'scripts' + scripts.mkdir() + battery = scripts / 'battery.sh' + cases = [ + ("printf 'Battery: fixture summary.\\n'\n", 'Battery: fixture summary.'), + ('exit 0\n', 'Battery: unavailable; setup will continue.'), + ("printf 'hidden probe failure\\n' >&2\nexit 23\n", + 'Battery: unavailable; setup will continue.'), + ] + for probe, expected in cases: + with self.subTest(probe=probe): + battery.write_text( + '#!/bin/bash\n' + 'if { : >&9; } 2>/dev/null; then echo inherited-lock >&2; exit 91; fi\n' + '[[ "${ONE_BITE_LOCKED:-}" == 0 ]] || { echo locked-environment >&2; exit 92; }\n' + '[[ "$TMPDIR" == "$EXPECTED_RUN_DIR" ]] || { echo wrong-tmpdir >&2; exit 93; }\n' + + probe + ) + result = self.run_shell( + f'ROOT={shlex.quote(str(fake_root))}; ' + 'RUN_DIR="$TEST_STATE/startup-run"; export RUN_DIR EXPECTED_RUN_DIR="$RUN_DIR"; ' + 'exec 9>"$TEST_STATE/session.lock"; export ONE_BITE_LOCKED=1; ' + 'uname() { echo arm64; }; sw_vers() { echo 26.6.2; }; id() { echo 501; }; ' + 'check_install_target; echo AFTER-TARGET' + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Target: Apple Silicon, macOS 26.6.2', result.stdout) + self.assertIn(expected, result.stdout) + self.assertIn('AFTER-TARGET', result.stdout) + self.assertNotIn('hidden probe failure', result.stdout + result.stderr) def test_cli_invalid_arguments_and_side_effect_free_plan(self): with tempfile.TemporaryDirectory() as directory: