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.
- junkicide-0.1.0/.github/workflows/ci.yml +21 -0
- junkicide-0.1.0/.github/workflows/publish.yml +37 -0
- junkicide-0.1.0/.gitignore +7 -0
- junkicide-0.1.0/LICENSE +21 -0
- junkicide-0.1.0/Makefile +48 -0
- junkicide-0.1.0/PKG-INFO +91 -0
- junkicide-0.1.0/README.md +63 -0
- junkicide-0.1.0/pyproject.toml +57 -0
- junkicide-0.1.0/src/junkicide/__init__.py +1 -0
- junkicide-0.1.0/src/junkicide/__main__.py +101 -0
- junkicide-0.1.0/src/junkicide/actions.py +183 -0
- junkicide-0.1.0/src/junkicide/apps.py +97 -0
- junkicide-0.1.0/src/junkicide/cache.py +51 -0
- junkicide-0.1.0/src/junkicide/config.py +67 -0
- junkicide-0.1.0/src/junkicide/journal.py +85 -0
- junkicide-0.1.0/src/junkicide/knowledge/__init__.py +0 -0
- junkicide-0.1.0/src/junkicide/knowledge/files.py +299 -0
- junkicide-0.1.0/src/junkicide/knowledge/paths.py +258 -0
- junkicide-0.1.0/src/junkicide/knowledge/processes.py +481 -0
- junkicide-0.1.0/src/junkicide/model.py +216 -0
- junkicide-0.1.0/src/junkicide/scanners/apps_unused.py +171 -0
- junkicide-0.1.0/src/junkicide/scanners/base.py +111 -0
- junkicide-0.1.0/src/junkicide/scanners/disk_known.py +383 -0
- junkicide-0.1.0/src/junkicide/scanners/disk_walk.py +453 -0
- junkicide-0.1.0/src/junkicide/scanners/docker.py +246 -0
- junkicide-0.1.0/src/junkicide/scanners/processes.py +445 -0
- junkicide-0.1.0/src/junkicide/scanners/system.py +118 -0
- junkicide-0.1.0/src/junkicide/tui/__init__.py +0 -0
- junkicide-0.1.0/src/junkicide/tui/app.py +462 -0
- junkicide-0.1.0/src/junkicide/tui/app.tcss +129 -0
- junkicide-0.1.0/src/junkicide/tui/render.py +192 -0
- junkicide-0.1.0/src/junkicide/tui/screens.py +179 -0
- junkicide-0.1.0/src/junkicide/util.py +226 -0
- junkicide-0.1.0/tests/test_classify.py +68 -0
- junkicide-0.1.0/tests/test_tui.py +61 -0
- 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
|
junkicide-0.1.0/LICENSE
ADDED
|
@@ -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.
|
junkicide-0.1.0/Makefile
ADDED
|
@@ -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"
|
junkicide-0.1.0/PKG-INFO
ADDED
|
@@ -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
|