diff --git a/CHANGELOG.md b/CHANGELOG.md index 598d1ac..9643628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed +- **Histórico e cooldown dos alertas saem da árvore do pacote.** `mm log` + / `mm history` / `mm alerts` gravam em + `~/Library/Application Support/mac-manager/` (no Linux do CI, + `~/.local/share/mac-manager`). Upgrade de pipx/Homebrew não apaga mais + o CSV. Dados antigos em `/logs/` são copiados na primeira + leitura. `MACMANAGER_LOGS_DIR` sobrescreve o caminho. - **`mm alerts` não dispara mais CRITICAL em Mac sem bateria.** Desktop ou `ioreg` vazio devolve percent=0 / design=0; o launchd a cada 15 min avisava "plug in the charger". Sem `DesignCapacity` o comando agora diff --git a/COMMANDS.md b/COMMANDS.md index 37703e2..6956225 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -173,7 +173,7 @@ mm doctor ## Battery — history and alerts ### `mm log` -**What it does:** writes **one snapshot** of the battery to `logs/battery.csv` (append-only CSV format). +**What it does:** writes **one snapshot** of the battery to `~/Library/Application Support/mac-manager/battery.csv` (append-only CSV format). **Columns:** `timestamp, percent, is_charging, power_source, cycle_count, max_capacity_mah, design_capacity_mah, health_percent, temperature_c` @@ -215,7 +215,7 @@ mm history -n 200 # last 200 mm alerts ``` -> The state of the last notification is kept in `logs/.alert_state` (JSON with timestamps), preventing spam. +> The state of the last notification is kept in `~/Library/Application Support/mac-manager/.alert_state` (JSON with timestamps), preventing spam. > Macs without a battery (desktop, or empty `ioreg`) produce no alerts. --- @@ -364,14 +364,14 @@ mm history -n 30 | File | What it has | |---|---| -| `logs/battery.csv` | Append-only history of battery measurements | -| `logs/.alert_state` | Timestamps of the last notification of each type (JSON, controls cooldown) | -| `logs/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` | -| `logs/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` | +| `~/Library/Application Support/mac-manager/battery.csv` | Append-only history of battery measurements | +| `~/Library/Application Support/mac-manager/.alert_state` | Timestamps of the last notification of each type (JSON, controls cooldown) | +| `logs/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` (source install) | +| `logs/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` (source install) | ## Reinstall / uninstall ```bash ./install.sh # creates venv, symlink ~/.local/bin/mm and loads launchd agents -./uninstall.sh # removes everything (preserves CSVs in logs/) +./uninstall.sh # removes everything (preserves CSVs in Application Support) ``` diff --git a/README.md b/README.md index df22fe5..4f0502b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ This will: - install `rich` and `psutil` - create the symlink `~/.local/bin/mm` - register two `launchd` agents: - - **daily log** at 09:00 → writes a snapshot to `logs/battery.csv` + - **daily log** at 09:00 → writes a snapshot to `~/Library/Application Support/mac-manager/battery.csv` - **battery alerts** every 15 min → native macOS notification Make sure `~/.local/bin` is in your `PATH`: @@ -113,7 +113,7 @@ To preserve the lithium-ion battery (every cycle counts!), the agent checks ever ## History -Every day at 9 AM, a snapshot is written to `logs/battery.csv`: +Every day at 9 AM, a snapshot is written to `~/Library/Application Support/mac-manager/battery.csv`: ```csv timestamp,percent,is_charging,power_source,cycle_count,max_capacity_mah,design_capacity_mah,health_percent,temperature_c @@ -156,7 +156,8 @@ mac-manager/ ./uninstall.sh ``` -Removes launchd agents, symlink and venv. Preserves the CSVs in `logs/`. +Removes launchd agents, symlink and venv. Preserves battery history in +`~/Library/Application Support/mac-manager/`. ## Status diff --git a/install.sh b/install.sh index 210de01..5319ae3 100755 --- a/install.sh +++ b/install.sh @@ -72,7 +72,7 @@ echo " mm log # battery snapshot → CSV" echo " mm history -n 20 # last measurements" echo "" echo "Active automation:" -echo " • Daily log at 09:00 → logs/battery.csv" +echo " • Daily log at 09:00 → ~/Library/Application Support/mac-manager/battery.csv" echo " • Battery alerts every 15 min (native notifications)" echo "" echo "To uninstall everything: ./uninstall.sh" diff --git a/macmanager/alerts.py b/macmanager/alerts.py index 5b4b8aa..ace426e 100644 --- a/macmanager/alerts.py +++ b/macmanager/alerts.py @@ -6,7 +6,8 @@ - Not charging and <= 10%: critical alert - Health < 80%: once per week -To avoid spam, we keep the state of the last notification in logs/.alert_state. +O estado da última notificação fica em .alert_state no diretório de dados +do usuário (Application Support), para o cooldown sobreviver a upgrades. """ from __future__ import annotations @@ -15,7 +16,7 @@ import time from macmanager.battery import get_battery -from macmanager.logger import ALERT_STATE, LOGS_DIR +from macmanager.logger import ALERT_STATE, ensure_logs from macmanager.notify import notify HIGH = 80 @@ -32,6 +33,7 @@ def _load_state() -> dict: + ensure_logs() if not ALERT_STATE.exists(): return {} try: @@ -41,7 +43,7 @@ def _load_state() -> dict: def _save_state(state: dict) -> None: - LOGS_DIR.mkdir(parents=True, exist_ok=True) + ensure_logs() ALERT_STATE.write_text(json.dumps(state, indent=2)) diff --git a/macmanager/logger.py b/macmanager/logger.py index 2bb159e..5824134 100644 --- a/macmanager/logger.py +++ b/macmanager/logger.py @@ -3,13 +3,63 @@ from __future__ import annotations import csv +import os +import platform +import shutil +from collections.abc import Mapping from datetime import datetime from pathlib import Path from macmanager.battery import get_battery from macmanager.ui import console -LOGS_DIR = Path(__file__).resolve().parent.parent / "logs" +_DATA_FILES = ("battery.csv", ".alert_state") +_LEGACY_LOGS_DIR = Path(__file__).resolve().parent.parent / "logs" + + +def default_logs_dir(*, home: Path | None = None, system: str | None = None) -> Path: + """Diretório estável do usuário — sobrevive a upgrade de pipx/Homebrew. + + No Mac: ~/Library/Application Support/mac-manager + Em outros SOs (CI Linux): ~/.local/share/mac-manager + """ + home = home or Path.home() + system = platform.system() if system is None else system + if system == "Darwin": + return home / "Library" / "Application Support" / "mac-manager" + return home / ".local" / "share" / "mac-manager" + + +def resolve_logs_dir( + *, + env: Mapping[str, str] | None = None, + home: Path | None = None, + system: str | None = None, +) -> Path: + """MACMANAGER_LOGS_DIR ganha; senão o diretório padrão da plataforma.""" + environ = os.environ if env is None else env + override = (environ.get("MACMANAGER_LOGS_DIR") or "").strip() + if override: + return Path(override).expanduser().resolve() + return default_logs_dir(home=home, system=system) + + +def migrate_legacy(legacy_dir: Path, dest_dir: Path) -> list[str]: + """Copia CSV/estado antigos se o destino ainda não tiver o arquivo.""" + copied: list[str] = [] + if legacy_dir.resolve() == dest_dir.resolve(): + return copied + for name in _DATA_FILES: + src = legacy_dir / name + dest = dest_dir / name + if src.is_file() and not dest.exists(): + dest_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + copied.append(name) + return copied + + +LOGS_DIR = resolve_logs_dir() BATTERY_CSV = LOGS_DIR / "battery.csv" ALERT_STATE = LOGS_DIR / ".alert_state" @@ -26,13 +76,16 @@ ] -def _ensure_dir() -> None: +def ensure_logs() -> Path: + """Garante o diretório e migra dados do `logs/` antigo do pacote.""" + migrate_legacy(_LEGACY_LOGS_DIR, LOGS_DIR) LOGS_DIR.mkdir(parents=True, exist_ok=True) + return LOGS_DIR def log_battery() -> dict: """Appends a battery snapshot to the CSV.""" - _ensure_dir() + ensure_logs() info = get_battery() row = { "timestamp": datetime.now().isoformat(timespec="seconds"), @@ -68,6 +121,7 @@ def cmd_history(args=None) -> None: """Shows the last N entries from the CSV.""" from rich.table import Table + ensure_logs() if not BATTERY_CSV.exists(): console.print("[yellow]No history yet. Run `mm log` or wait for launchd.[/]") return diff --git a/tests/test_logger.py b/tests/test_logger.py index 399633c..ce97f43 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -10,7 +10,15 @@ from pathlib import Path -from macmanager.logger import ALERT_STATE, BATTERY_CSV, BATTERY_FIELDS, LOGS_DIR +from macmanager.logger import ( + ALERT_STATE, + BATTERY_CSV, + BATTERY_FIELDS, + LOGS_DIR, + default_logs_dir, + migrate_legacy, + resolve_logs_dir, +) class TestBatteryFields: @@ -56,3 +64,63 @@ def test_alert_state_lives_under_logs_dir(self) -> None: assert ALERT_STATE.parent == LOGS_DIR # Dotfile so it doesn't clutter `ls`. assert ALERT_STATE.name.startswith(".") + + def test_logs_dir_is_not_inside_the_package(self) -> None: + # pipx/Homebrew upgrade apagava site-packages/logs/. O diretório + # de dados tem que viver fora da árvore do pacote. + package_root = Path(__file__).resolve().parents[1] + # tests/ está na raiz do repo; o logger antigo usava /logs. + assert package_root / "logs" != LOGS_DIR + + +class TestResolveLogsDir: + def test_env_override_wins(self, tmp_path: Path) -> None: + resolved = resolve_logs_dir(env={"MACMANAGER_LOGS_DIR": str(tmp_path)}) + assert resolved == tmp_path.resolve() + + def test_empty_override_falls_back_to_default(self, tmp_path: Path) -> None: + resolved = resolve_logs_dir( + env={"MACMANAGER_LOGS_DIR": " "}, + home=tmp_path, + system="Darwin", + ) + assert resolved == tmp_path / "Library" / "Application Support" / "mac-manager" + + def test_darwin_uses_application_support(self, tmp_path: Path) -> None: + assert default_logs_dir(home=tmp_path, system="Darwin") == ( + tmp_path / "Library" / "Application Support" / "mac-manager" + ) + + def test_linux_uses_xdg_data_home(self, tmp_path: Path) -> None: + assert default_logs_dir(home=tmp_path, system="Linux") == ( + tmp_path / ".local" / "share" / "mac-manager" + ) + + +class TestMigrateLegacy: + def test_copies_missing_files(self, tmp_path: Path) -> None: + legacy = tmp_path / "legacy" + dest = tmp_path / "dest" + legacy.mkdir() + (legacy / "battery.csv").write_text("ts,percent\n", encoding="utf-8") + (legacy / ".alert_state").write_text("{}", encoding="utf-8") + + copied = migrate_legacy(legacy, dest) + assert set(copied) == {"battery.csv", ".alert_state"} + assert (dest / "battery.csv").read_text(encoding="utf-8") == "ts,percent\n" + assert (dest / ".alert_state").read_text(encoding="utf-8") == "{}" + + def test_does_not_overwrite_existing_dest(self, tmp_path: Path) -> None: + legacy = tmp_path / "legacy" + dest = tmp_path / "dest" + legacy.mkdir() + dest.mkdir() + (legacy / "battery.csv").write_text("old\n", encoding="utf-8") + (dest / "battery.csv").write_text("new\n", encoding="utf-8") + + assert migrate_legacy(legacy, dest) == [] + assert (dest / "battery.csv").read_text(encoding="utf-8") == "new\n" + + def test_same_dir_is_a_noop(self, tmp_path: Path) -> None: + (tmp_path / "battery.csv").write_text("x\n", encoding="utf-8") + assert migrate_legacy(tmp_path, tmp_path) == [] diff --git a/uninstall.sh b/uninstall.sh index dd06d92..e288bc9 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -27,4 +27,4 @@ if [ -d "$SCRIPT_DIR/.venv" ]; then fi echo "" -echo "Uninstalled. The logs in logs/ were preserved." +echo "Uninstalled. Battery history in ~/Library/Application Support/mac-manager/ was preserved."