junkicide 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. junkicide-0.1.0/.github/workflows/ci.yml +21 -0
  2. junkicide-0.1.0/.github/workflows/publish.yml +37 -0
  3. junkicide-0.1.0/.gitignore +7 -0
  4. junkicide-0.1.0/LICENSE +21 -0
  5. junkicide-0.1.0/Makefile +48 -0
  6. junkicide-0.1.0/PKG-INFO +91 -0
  7. junkicide-0.1.0/README.md +63 -0
  8. junkicide-0.1.0/pyproject.toml +57 -0
  9. junkicide-0.1.0/src/junkicide/__init__.py +1 -0
  10. junkicide-0.1.0/src/junkicide/__main__.py +101 -0
  11. junkicide-0.1.0/src/junkicide/actions.py +183 -0
  12. junkicide-0.1.0/src/junkicide/apps.py +97 -0
  13. junkicide-0.1.0/src/junkicide/cache.py +51 -0
  14. junkicide-0.1.0/src/junkicide/config.py +67 -0
  15. junkicide-0.1.0/src/junkicide/journal.py +85 -0
  16. junkicide-0.1.0/src/junkicide/knowledge/__init__.py +0 -0
  17. junkicide-0.1.0/src/junkicide/knowledge/files.py +299 -0
  18. junkicide-0.1.0/src/junkicide/knowledge/paths.py +258 -0
  19. junkicide-0.1.0/src/junkicide/knowledge/processes.py +481 -0
  20. junkicide-0.1.0/src/junkicide/model.py +216 -0
  21. junkicide-0.1.0/src/junkicide/scanners/apps_unused.py +171 -0
  22. junkicide-0.1.0/src/junkicide/scanners/base.py +111 -0
  23. junkicide-0.1.0/src/junkicide/scanners/disk_known.py +383 -0
  24. junkicide-0.1.0/src/junkicide/scanners/disk_walk.py +453 -0
  25. junkicide-0.1.0/src/junkicide/scanners/docker.py +246 -0
  26. junkicide-0.1.0/src/junkicide/scanners/processes.py +445 -0
  27. junkicide-0.1.0/src/junkicide/scanners/system.py +118 -0
  28. junkicide-0.1.0/src/junkicide/tui/__init__.py +0 -0
  29. junkicide-0.1.0/src/junkicide/tui/app.py +462 -0
  30. junkicide-0.1.0/src/junkicide/tui/app.tcss +129 -0
  31. junkicide-0.1.0/src/junkicide/tui/render.py +192 -0
  32. junkicide-0.1.0/src/junkicide/tui/screens.py +179 -0
  33. junkicide-0.1.0/src/junkicide/util.py +226 -0
  34. junkicide-0.1.0/tests/test_classify.py +68 -0
  35. junkicide-0.1.0/tests/test_tui.py +61 -0
  36. junkicide-0.1.0/uv.lock +254 -0
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: macos-latest
11
+ strategy:
12
+ matrix:
13
+ python: ["3.11", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v6
17
+ with:
18
+ python-version: ${{ matrix.python }}
19
+ - run: uv sync
20
+ - run: uv run ruff check src tests
21
+ - run: uv run pytest -q
@@ -0,0 +1,37 @@
1
+ name: Publish to PyPI
2
+
3
+ # Публикация по тегу вида v0.1.0. Без токенов: PyPI Trusted Publishing (OIDC).
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: macos-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v6
14
+ - name: Версия тега совпадает с версией пакета
15
+ run: |
16
+ v=$(uv run --no-project python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
17
+ test "v$v" = "${GITHUB_REF_NAME}" || { echo "тег ${GITHUB_REF_NAME} ≠ версия $v"; exit 1; }
18
+ - run: uv sync
19
+ - run: uv run pytest -q
20
+ - run: uv build
21
+ - uses: actions/upload-artifact@v4
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ environment: pypi
30
+ permissions:
31
+ id-token: write
32
+ steps:
33
+ - uses: actions/download-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 trapwalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ # junkicide — управление проектом
2
+ .PHONY: help install run report report-quick test lint fmt build clean uvx journal config release
3
+
4
+ help: ## Показать команды
5
+ @grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
6
+
7
+ install: ## Установить зависимости (uv sync)
8
+ uv sync
9
+
10
+ run: ## Запустить TUI из исходников
11
+ uv run junkicide
12
+
13
+ report: ## Текстовый отчёт без интерфейса (полный обход, до 2 минут)
14
+ uv run junkicide --report 120 -v
15
+
16
+ report-quick: ## Быстрый отчёт: процессы и известные места, без обхода диска
17
+ uv run junkicide --report 40 --no-walk
18
+
19
+ test: ## Тесты
20
+ uv run pytest -q
21
+
22
+ lint: ## Проверка стиля
23
+ uv run ruff check src tests
24
+
25
+ fmt: ## Автоисправление стиля
26
+ uv run ruff check --fix src tests
27
+
28
+ build: ## Собрать wheel/sdist в dist/
29
+ uv build
30
+
31
+ uvx: ## Запустить так, как это сделает пользователь (uvx из локальной папки)
32
+ uvx --from . junkicide
33
+
34
+ journal: ## Открыть журнал действий
35
+ open -R ~/Library/Logs/junkicide/journal.jsonl
36
+
37
+ config: ## Открыть файл настроек
38
+ @test -f ~/.config/junkicide/config.toml || uv run python -c "from junkicide.config import Config; Config.load().save()"
39
+ open ~/.config/junkicide/config.toml
40
+
41
+ clean: ## Удалить артефакты сборки и кэш прошлых сканирований
42
+ rm -rf dist build .pytest_cache .ruff_cache
43
+ rm -f ~/Library/Caches/junkicide/findings.json
44
+
45
+ release: ## Выпустить версию из pyproject.toml: тег vX.Y.Z → GitHub Actions публикует в PyPI
46
+ @v=$$(uv run python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])"); \
47
+ git diff --quiet && git diff --cached --quiet || { echo "есть незакоммиченные изменения"; exit 1; }; \
48
+ echo "Выпускаю v$$v"; git tag -a "v$$v" -m "v$$v" && git push origin main "v$$v"
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: junkicide
3
+ Version: 0.1.0
4
+ Summary: TUI для macOS: найти и осознанно прибить забытые прожорливые процессы и ненужные гигабайты на диске
5
+ Project-URL: Homepage, https://github.com/trapwalker/junkicide
6
+ Project-URL: Issues, https://github.com/trapwalker/junkicide/issues
7
+ Author: trapwalker
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: android-emulator,cleanup,disk-space,docker,macos,process-monitor,tui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Natural Language :: Russian
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: psutil>=6.0
26
+ Requires-Dist: textual>=0.80
27
+ Description-Content-Type: text/markdown
28
+
29
+ # junkicide
30
+
31
+ TUI для macOS: быстро найти и **осознанно** прибить то, что зря жрёт ресурсы, — забытые эмуляторы,
32
+ Docker-контейнеры, dev-серверы, тяжёлые вкладки браузера — и то, что зря занимает диск: старые дистрибутивы,
33
+ программы, которыми не пользуются, фильмы, записи видеорегистратора, кэши и артефакты сборки заброшенных
34
+ проектов.
35
+
36
+ ```sh
37
+ uv tool install --editable ~/projects/own/junkicide # один раз; дальше просто:
38
+ junkicide
39
+ ```
40
+
41
+ Без установки: `uvx --from ~/projects/own/junkicide junkicide`; после публикации на PyPI — просто `uvx junkicide`.
42
+
43
+ ## Как работает
44
+
45
+ 1. **Сразу** — сводка (CPU, давление памяти, swap, свободное место, нагрев) и очевидные проблемы: процессы,
46
+ которые устойчиво грузят CPU или держат много памяти, известные «пожиратели» (эмулятор Android, Docker VM,
47
+ демоны Gradle, dev-серверы…), крупные файлы из индекса Spotlight.
48
+ 2. **Параллельно в фоне** — известные места на диске (кэши, Xcode, Android SDK/AVD, Homebrew, модели нейросетей…),
49
+ давно не используемые программы и полный обход домашней папки: артефакты сборки по проектам (с датой, когда
50
+ проект последний раз менялся), папки с видео (распознаются записи регистраторов), дубликаты, недокачанные
51
+ файлы, архивы, уже распакованные рядом.
52
+ 3. Для каждой находки — **что это, откуда взялось, чем грозит устранение**, факты (пути, даты, PID, порты,
53
+ рабочая папка, откуда скачан файл) и оценка, насколько вероятно это не нужно.
54
+ 4. Устранение — клавишей, с подтверждением. Файлы — **только в Корзину**; процессы — SIGTERM, SIGKILL по выбору;
55
+ для кэшей — штатные команды (`brew cleanup`, `docker builder prune`, `npm cache clean`…). Корзину можно
56
+ очистить отсюда же (`E`).
57
+
58
+ Результаты сканирования диска кэшируются: при следующем запуске прошлая картина видна сразу (⏳), пока идёт
59
+ перепроверка.
60
+
61
+ ## Клавиши
62
+
63
+ `1`–`5` вкладки (Главное, CPU, Память, Диск, Журнал) · `Enter`/`a` действия · `k`/`K` завершить/убить ·
64
+ `d` в Корзину · `пробел` отметить несколько · `o` Finder · `i` скрыть находку · `/` фильтр (`#dev`, `#cache`,
65
+ `#забытое`…) · `s` сортировка по выгоде/размеру · `r` пересканировать · `E` очистить Корзину · `?` помощь
66
+
67
+ ## Без интерфейса
68
+
69
+ ```sh
70
+ junkicide --report # просканировать (до 2 мин) и вывести отчёт
71
+ junkicide --report 40 --no-walk -v
72
+ ```
73
+
74
+ ## Файлы
75
+
76
+ | | |
77
+ |---|---|
78
+ | `~/.config/junkicide/config.toml` | пороги и список скрытых находок (переносим между компьютерами) |
79
+ | `~/Library/Logs/junkicide/journal.jsonl` | журнал: все действия и события сканирования |
80
+ | `~/Library/Caches/junkicide/findings.json` | кэш прошлого сканирования диска (можно удалить) |
81
+
82
+ Для полной картины (Корзина, Почта, данные некоторых программ) дайте терминалу
83
+ «Полный доступ к диску»: Системные настройки → Конфиденциальность и безопасность.
84
+
85
+ ## Разработка
86
+
87
+ `make help` — все команды: `make run`, `make test`, `make lint`, `make report`, `make uvx`.
88
+
89
+ Устройство: `scanners/` — независимые сканеры в фоновых потоках, пишут находки в общее хранилище
90
+ (`model.FindingStore`); `knowledge/` — база знаний (процессы, известные места, классификация файлов) с
91
+ объяснениями; `actions.py` — исполнение действий с журналированием; `tui/` — интерфейс на Textual.
@@ -0,0 +1,63 @@
1
+ # junkicide
2
+
3
+ TUI для macOS: быстро найти и **осознанно** прибить то, что зря жрёт ресурсы, — забытые эмуляторы,
4
+ Docker-контейнеры, dev-серверы, тяжёлые вкладки браузера — и то, что зря занимает диск: старые дистрибутивы,
5
+ программы, которыми не пользуются, фильмы, записи видеорегистратора, кэши и артефакты сборки заброшенных
6
+ проектов.
7
+
8
+ ```sh
9
+ uv tool install --editable ~/projects/own/junkicide # один раз; дальше просто:
10
+ junkicide
11
+ ```
12
+
13
+ Без установки: `uvx --from ~/projects/own/junkicide junkicide`; после публикации на PyPI — просто `uvx junkicide`.
14
+
15
+ ## Как работает
16
+
17
+ 1. **Сразу** — сводка (CPU, давление памяти, swap, свободное место, нагрев) и очевидные проблемы: процессы,
18
+ которые устойчиво грузят CPU или держат много памяти, известные «пожиратели» (эмулятор Android, Docker VM,
19
+ демоны Gradle, dev-серверы…), крупные файлы из индекса Spotlight.
20
+ 2. **Параллельно в фоне** — известные места на диске (кэши, Xcode, Android SDK/AVD, Homebrew, модели нейросетей…),
21
+ давно не используемые программы и полный обход домашней папки: артефакты сборки по проектам (с датой, когда
22
+ проект последний раз менялся), папки с видео (распознаются записи регистраторов), дубликаты, недокачанные
23
+ файлы, архивы, уже распакованные рядом.
24
+ 3. Для каждой находки — **что это, откуда взялось, чем грозит устранение**, факты (пути, даты, PID, порты,
25
+ рабочая папка, откуда скачан файл) и оценка, насколько вероятно это не нужно.
26
+ 4. Устранение — клавишей, с подтверждением. Файлы — **только в Корзину**; процессы — SIGTERM, SIGKILL по выбору;
27
+ для кэшей — штатные команды (`brew cleanup`, `docker builder prune`, `npm cache clean`…). Корзину можно
28
+ очистить отсюда же (`E`).
29
+
30
+ Результаты сканирования диска кэшируются: при следующем запуске прошлая картина видна сразу (⏳), пока идёт
31
+ перепроверка.
32
+
33
+ ## Клавиши
34
+
35
+ `1`–`5` вкладки (Главное, CPU, Память, Диск, Журнал) · `Enter`/`a` действия · `k`/`K` завершить/убить ·
36
+ `d` в Корзину · `пробел` отметить несколько · `o` Finder · `i` скрыть находку · `/` фильтр (`#dev`, `#cache`,
37
+ `#забытое`…) · `s` сортировка по выгоде/размеру · `r` пересканировать · `E` очистить Корзину · `?` помощь
38
+
39
+ ## Без интерфейса
40
+
41
+ ```sh
42
+ junkicide --report # просканировать (до 2 мин) и вывести отчёт
43
+ junkicide --report 40 --no-walk -v
44
+ ```
45
+
46
+ ## Файлы
47
+
48
+ | | |
49
+ |---|---|
50
+ | `~/.config/junkicide/config.toml` | пороги и список скрытых находок (переносим между компьютерами) |
51
+ | `~/Library/Logs/junkicide/journal.jsonl` | журнал: все действия и события сканирования |
52
+ | `~/Library/Caches/junkicide/findings.json` | кэш прошлого сканирования диска (можно удалить) |
53
+
54
+ Для полной картины (Корзина, Почта, данные некоторых программ) дайте терминалу
55
+ «Полный доступ к диску»: Системные настройки → Конфиденциальность и безопасность.
56
+
57
+ ## Разработка
58
+
59
+ `make help` — все команды: `make run`, `make test`, `make lint`, `make report`, `make uvx`.
60
+
61
+ Устройство: `scanners/` — независимые сканеры в фоновых потоках, пишут находки в общее хранилище
62
+ (`model.FindingStore`); `knowledge/` — база знаний (процессы, известные места, классификация файлов) с
63
+ объяснениями; `actions.py` — исполнение действий с журналированием; `tui/` — интерфейс на Textual.
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "junkicide"
3
+ version = "0.1.0"
4
+ description = "TUI для macOS: найти и осознанно прибить забытые прожорливые процессы и ненужные гигабайты на диске"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "trapwalker" }]
10
+ keywords = ["macos", "tui", "cleanup", "disk-space", "process-monitor", "docker", "android-emulator"]
11
+ dependencies = [
12
+ "textual>=0.80",
13
+ "psutil>=6.0",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "Natural Language :: Russian",
21
+ "Operating System :: MacOS",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: System :: Monitoring",
28
+ "Topic :: Utilities",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/trapwalker/junkicide"
33
+ Issues = "https://github.com/trapwalker/junkicide/issues"
34
+
35
+ [project.scripts]
36
+ junkicide = "junkicide.__main__:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling>=1.27"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/junkicide"]
44
+
45
+ [dependency-groups]
46
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5"]
47
+
48
+ [tool.ruff]
49
+ line-length = 110
50
+ target-version = "py311"
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "W", "I", "B", "UP", "RUF100"]
57
+ ignore = ["E731", "B023", "E501"] # длину держим ~110, но эмодзи и русские тексты ruff считает неточно
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,101 @@
1
+ """Точка входа: `junkicide` (TUI) или `junkicide --report` (текстовый отчёт для терминала/скриптов)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+
9
+ from . import __version__
10
+ from .config import Config
11
+ from .journal import Journal
12
+
13
+
14
+ def make_scanners(args) -> list:
15
+ from .scanners.apps_unused import UnusedAppsScanner
16
+ from .scanners.disk_known import KnownPathsScanner
17
+ from .scanners.disk_walk import HomeWalkScanner, SpotlightBigFiles
18
+ from .scanners.docker import DockerScanner
19
+ from .scanners.processes import ProcessScanner
20
+ from .scanners.system import SystemScanner
21
+
22
+ scanners = [SystemScanner(), ProcessScanner(), DockerScanner()]
23
+ if not args.no_disk:
24
+ scanners += [SpotlightBigFiles(), KnownPathsScanner(), UnusedAppsScanner()]
25
+ if not args.no_walk:
26
+ scanners.append(HomeWalkScanner())
27
+ return scanners
28
+
29
+
30
+ def report(args, config: Config, journal: Journal) -> int:
31
+ from .model import RISK_META, FindingStore, Resource
32
+ from .scanners.base import Runner, ScanContext
33
+ from .util import human_bytes
34
+
35
+ store = FindingStore()
36
+ store.set_ignored(set(config.ignore_ids))
37
+ ctx = ScanContext(store, journal, config)
38
+ runner = Runner(ctx, make_scanners(args))
39
+ runner.start()
40
+ deadline = time.time() + args.report
41
+ once = [n for n, st in ctx.status.items() if n not in ("system", "processes", "docker")]
42
+ while time.time() < deadline:
43
+ time.sleep(0.5)
44
+ if time.time() > deadline - args.report + 5 and all(ctx.status[n].runs > 0 for n in once):
45
+ break
46
+ if sys.stderr.isatty():
47
+ busy = [f"{st.title}: {st.message}" for st in ctx.status.values() if st.state == "идёт"]
48
+ print("\r\033[K⟳ " + "; ".join(busy)[:150], end="", file=sys.stderr, flush=True)
49
+ if sys.stderr.isatty():
50
+ print("\r\033[K", end="", file=sys.stderr)
51
+ runner.stop()
52
+ items = sorted(store.snapshot(), key=lambda f: -f.score)
53
+ sections = [
54
+ ("🔥 Нагрузка на CPU и 🧠 память", [f for f in items if f.resource in (Resource.CPU, Resource.MEMORY)]),
55
+ ("💾 Диск", [f for f in items if f.resource == Resource.DISK]),
56
+ ("ℹ️ Сведения", [f for f in items if f.resource == Resource.INFO]),
57
+ ]
58
+ for title, fs in sections:
59
+ print(f"\n{title}")
60
+ for f in fs[: args.top]:
61
+ cpu = f" CPU {f.cpu:.0f}%" if f.cpu >= 1 else ""
62
+ size = human_bytes(f.bytes) if f.bytes else ""
63
+ print(f" {RISK_META[f.risk][0]} {size:>9}{cpu:>10} {f.confidence * 100:3.0f}% {f.title}")
64
+ if f.location and args.verbose:
65
+ print(f"{'':30}{f.location}")
66
+ print()
67
+ not_done = [st.title for st in ctx.status.values() if st.runs == 0]
68
+ if not_done:
69
+ print(f"(не успели за {args.report} с: {', '.join(not_done)} — увеличьте --report)")
70
+ return 0
71
+
72
+
73
+ def main(argv: list[str] | None = None) -> int:
74
+ p = argparse.ArgumentParser(
75
+ prog="junkicide",
76
+ description="Найти и осознанно прибить забытые прожорливые процессы и ненужные гигабайты на macOS.",
77
+ )
78
+ p.add_argument("--report", type=int, metavar="СЕК", nargs="?", const=120,
79
+ help="не открывать интерфейс: просканировать (до СЕК секунд, по умолчанию 120) и вывести отчёт")
80
+ p.add_argument("--top", type=int, default=15, help="сколько строк в каждом разделе отчёта")
81
+ p.add_argument("-v", "--verbose", action="store_true", help="в отчёте показывать пути")
82
+ p.add_argument("--no-walk", action="store_true", help="не делать полный обход домашней папки (быстрее)")
83
+ p.add_argument("--no-disk", action="store_true", help="только процессы, без анализа диска")
84
+ p.add_argument("--version", action="version", version=f"junkicide {__version__}")
85
+ args = p.parse_args(argv)
86
+
87
+ if sys.platform != "darwin":
88
+ print("junkicide рассчитан на macOS.", file=sys.stderr)
89
+ config = Config.load()
90
+ journal = Journal()
91
+ if args.report is not None:
92
+ return report(args, config, journal)
93
+
94
+ from .tui.app import JunkicideApp
95
+
96
+ JunkicideApp(config, journal, make_scanners(args)).run()
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())
@@ -0,0 +1,183 @@
1
+ """Исполнение действий. Каждое действие журналируется; удаление — только в Корзину."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import time
9
+ from dataclasses import dataclass
10
+
11
+ import psutil
12
+
13
+ from .journal import Journal
14
+ from .model import Action, ActionKind, Finding
15
+ from .util import du_bytes, human_bytes, run, short_path
16
+
17
+
18
+ @dataclass
19
+ class Result:
20
+ ok: bool
21
+ message: str
22
+ output: str = ""
23
+ freed: int = 0
24
+ resolved: bool = False # находку можно считать устранённой
25
+
26
+
27
+ def _same_process(pid: int, ctime: float) -> psutil.Process | None:
28
+ try:
29
+ p = psutil.Process(pid)
30
+ if ctime and abs(p.create_time() - ctime) > 1:
31
+ return None # pid уже занят другим процессом
32
+ return p
33
+ except psutil.Error:
34
+ return None
35
+
36
+
37
+ def kill(pids: list[tuple[int, float]], force: bool, wait: float = 5.0) -> Result:
38
+ procs = [p for pid, ct in pids if (p := _same_process(pid, ct))]
39
+ if not procs:
40
+ return Result(True, "Процесс уже завершён (если его PID занят новым процессом — тот не тронут)",
41
+ resolved=True)
42
+ sig = signal.SIGKILL if force else signal.SIGTERM
43
+ errors = []
44
+ for p in procs:
45
+ try:
46
+ p.send_signal(sig)
47
+ except psutil.NoSuchProcess:
48
+ pass
49
+ except psutil.AccessDenied:
50
+ errors.append(f"{p.pid}: нет прав (процесс другого пользователя или системный)")
51
+ _, alive = psutil.wait_procs(procs, timeout=wait)
52
+ if errors:
53
+ return Result(False, "; ".join(errors))
54
+ if alive:
55
+ names = ", ".join(str(p.pid) for p in alive)
56
+ return Result(
57
+ False,
58
+ f"Ещё живы: {names}. Процесс не ответил на SIGTERM за {wait:.0f} с — "
59
+ "можно завершить принудительно (SIGKILL).",
60
+ )
61
+ return Result(True, f"Завершено процессов: {len(procs)} ({sig.name})", resolved=True)
62
+
63
+
64
+ def quit_app(app_name: str, wait: float = 10.0) -> Result:
65
+ code, out, err = run(["osascript", "-e", f'tell application "{app_name}" to quit'], timeout=wait + 20)
66
+ if code != 0:
67
+ return Result(False, f"Приложение не закрылось: {err.strip() or out.strip()}")
68
+ return Result(True, f"{app_name}: отправлена команда выхода", resolved=True)
69
+
70
+
71
+ def _trash_one(path: str) -> tuple[bool, str]:
72
+ if not os.path.lexists(path):
73
+ return True, "уже нет"
74
+ trash_bin = shutil.which("trash") or ("/usr/bin/trash" if os.path.exists("/usr/bin/trash") else None)
75
+ if trash_bin:
76
+ code, out, err = run([trash_bin, path], timeout=600)
77
+ if code == 0 and not os.path.lexists(path):
78
+ return True, ""
79
+ # Запасной путь — Finder (он же умеет «Вернуть» из Корзины)
80
+ escaped = path.replace("\\", "\\\\").replace('"', '\\"')
81
+ code, out, err = run(
82
+ ["osascript", "-e", f'tell application "Finder" to delete (POSIX file "{escaped}" as alias)'], timeout=600
83
+ )
84
+ if code == 0 and not os.path.lexists(path):
85
+ return True, ""
86
+ return False, (err or out).strip() or "не удалось"
87
+
88
+
89
+ def trash(paths: list[str]) -> Result:
90
+ freed = 0
91
+ failed = []
92
+ for p in paths:
93
+ size = du_bytes(p) if os.path.isdir(p) else (os.lstat(p).st_blocks * 512 if os.path.lexists(p) else 0)
94
+ ok, msg = _trash_one(p)
95
+ if ok:
96
+ freed += size
97
+ else:
98
+ failed.append(f"{short_path(p)}: {msg}")
99
+ if failed:
100
+ return Result(bool(freed), "Не всё удалось переместить: " + "; ".join(failed), freed=freed,
101
+ resolved=False)
102
+ return Result(True, f"В Корзину: {human_bytes(freed)}. Место освободится после очистки Корзины.",
103
+ freed=freed, resolved=True)
104
+
105
+
106
+ def command(argv: list[str], cwd: str | None = None, timeout: float = 1800) -> Result:
107
+ code, out, err = run(argv, timeout=timeout, cwd=cwd)
108
+ text = (out + ("\n" + err if err else "")).strip()
109
+ if code == 0:
110
+ return Result(True, "Готово: " + " ".join(argv), output=text, resolved=True)
111
+ return Result(False, f"Команда завершилась с кодом {code}: {' '.join(argv)}", output=text)
112
+
113
+
114
+ def trash_size() -> tuple[int, int | None]:
115
+ """(байт, количество объектов или None, если нет доступа)."""
116
+ from .util import HOME
117
+
118
+ tpath = HOME / ".Trash"
119
+ try:
120
+ n = len(os.listdir(tpath))
121
+ return du_bytes(tpath), n
122
+ except PermissionError:
123
+ # Без Full Disk Access каталог закрыт — спросим Finder
124
+ code, out, _ = run(["osascript", "-e", 'tell application "Finder" to count items of trash'], timeout=20)
125
+ try:
126
+ n = int(out.strip())
127
+ except ValueError:
128
+ n = None
129
+ return 0, n
130
+ except OSError:
131
+ return 0, 0
132
+
133
+
134
+ def empty_trash() -> Result:
135
+ before = psutil.disk_usage("/").free
136
+ code, out, err = run(
137
+ ["osascript", "-e", 'tell application "Finder" to empty trash'], timeout=3600
138
+ )
139
+ time.sleep(1)
140
+ freed = max(psutil.disk_usage("/").free - before, 0)
141
+ if code != 0:
142
+ return Result(False, f"Не удалось очистить Корзину: {(err or out).strip()}")
143
+ return Result(True, f"Корзина очищена, освобождено ≈ {human_bytes(freed)}", freed=freed, resolved=True)
144
+
145
+
146
+ def execute(action: Action, finding: Finding | None, journal: Journal) -> Result:
147
+ target = finding.title if finding else ""
148
+ k = action.kind
149
+ try:
150
+ if k == ActionKind.KILL:
151
+ res = kill(action.pids, action.force)
152
+ elif k == ActionKind.QUIT_APP:
153
+ res = quit_app(action.app_name or "")
154
+ elif k == ActionKind.TRASH:
155
+ res = trash(action.paths)
156
+ elif k in (ActionKind.COMMAND, ActionKind.SHOW):
157
+ res = command(action.argv, action.cwd)
158
+ if k == ActionKind.SHOW:
159
+ res.resolved = False
160
+ elif k == ActionKind.EMPTY_TRASH:
161
+ res = empty_trash()
162
+ elif k == ActionKind.REVEAL:
163
+ path = action.paths[0] if action.paths else ""
164
+ # Корзину открываем как папку, остальное — выделяем в Finder
165
+ code, _, err = run(["open", path] if path.endswith("/.Trash") else ["open", "-R", path])
166
+ res = Result(code == 0, "Показано в Finder" if code == 0 else err.strip())
167
+ else:
168
+ res = Result(False, f"Неизвестное действие {k}")
169
+ except Exception as e: # действие не должно ронять интерфейс
170
+ res = Result(False, f"Ошибка: {e!r}")
171
+ if k not in (ActionKind.REVEAL, ActionKind.SHOW):
172
+ journal.log(
173
+ "action" if res.ok else "error",
174
+ f"action.{k}",
175
+ f"{action.label} — {target}: {res.message}",
176
+ finding=finding.id if finding else None,
177
+ paths=action.paths,
178
+ pids=[p for p, _ in action.pids],
179
+ argv=action.argv,
180
+ freed=res.freed,
181
+ ok=res.ok,
182
+ )
183
+ return res