macbroom 1.1.0__py3-none-any.whl
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.
- macbroom/__init__.py +5 -0
- macbroom/__main__.py +8 -0
- macbroom/cli.py +128 -0
- macbroom/core/__init__.py +1 -0
- macbroom/core/audit.py +61 -0
- macbroom/core/fsutil.py +186 -0
- macbroom/core/i18n.py +358 -0
- macbroom/core/model.py +68 -0
- macbroom/core/server.py +201 -0
- macbroom/core/trash.py +88 -0
- macbroom/doctor.py +127 -0
- macbroom/scanners/__init__.py +66 -0
- macbroom/scanners/app_leftovers.py +128 -0
- macbroom/scanners/appindex.py +98 -0
- macbroom/scanners/caches.py +197 -0
- macbroom/scanners/duplicates.py +133 -0
- macbroom/scanners/ios_dev.py +271 -0
- macbroom/scanners/large_files.py +74 -0
- macbroom/scanners/login_items.py +139 -0
- macbroom/scanners/system_extras.py +217 -0
- macbroom/web/app.js +868 -0
- macbroom/web/index.html +102 -0
- macbroom/web/style.css +362 -0
- macbroom-1.1.0.dist-info/METADATA +181 -0
- macbroom-1.1.0.dist-info/RECORD +29 -0
- macbroom-1.1.0.dist-info/WHEEL +5 -0
- macbroom-1.1.0.dist-info/entry_points.txt +2 -0
- macbroom-1.1.0.dist-info/licenses/LICENSE +21 -0
- macbroom-1.1.0.dist-info/top_level.txt +1 -0
macbroom/__init__.py
ADDED
macbroom/__main__.py
ADDED
macbroom/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""MacBroom CLI —— 启动本地服务,或在终端直接扫描出报告。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
macbroom # 默认 127.0.0.1:37700,启动 Web UI 并打开浏览器
|
|
5
|
+
macbroom --port 40000
|
|
6
|
+
macbroom --no-open # 不自动打开浏览器
|
|
7
|
+
python -m macbroom # 等价调用
|
|
8
|
+
|
|
9
|
+
macbroom scan # 在终端扫描并打印汇总(不启动服务)
|
|
10
|
+
macbroom scan --json # 输出 JSON,便于脚本 / CI 消费
|
|
11
|
+
macbroom scan --lang en
|
|
12
|
+
macbroom scan --category caches,login_items
|
|
13
|
+
|
|
14
|
+
仅依赖 Python 3 标准库。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
import webbrowser
|
|
23
|
+
|
|
24
|
+
from macbroom.core import audit
|
|
25
|
+
from macbroom.core.fsutil import human_size
|
|
26
|
+
from macbroom.core.i18n import normalize_lang
|
|
27
|
+
from macbroom.core.server import serve
|
|
28
|
+
from macbroom.scanners import categories as list_categories, scan_category
|
|
29
|
+
|
|
30
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
31
|
+
DEFAULT_PORT = 37700 # 已在端口登记表登记
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_serve(args: argparse.Namespace) -> None:
|
|
35
|
+
if not args.no_open:
|
|
36
|
+
url = f"http://{args.host}:{args.port}"
|
|
37
|
+
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
|
38
|
+
serve(args.host, args.port)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_scan(args: argparse.Namespace) -> None:
|
|
42
|
+
lang = normalize_lang(args.lang)
|
|
43
|
+
cats = list_categories(lang)
|
|
44
|
+
title_by_key = {c.key: c.title for c in cats}
|
|
45
|
+
icon_by_key = {c.key: c.icon for c in cats}
|
|
46
|
+
|
|
47
|
+
if args.category:
|
|
48
|
+
keys = [k.strip() for k in args.category.split(",") if k.strip()]
|
|
49
|
+
else:
|
|
50
|
+
keys = [c.key for c in cats]
|
|
51
|
+
|
|
52
|
+
results: list[tuple[str, list]] = []
|
|
53
|
+
for key in keys:
|
|
54
|
+
results.append((key, scan_category(key, lang)))
|
|
55
|
+
|
|
56
|
+
all_items = [it for _, items in results for it in items]
|
|
57
|
+
audit.record("cli_scan", categories=keys, count=len(all_items))
|
|
58
|
+
|
|
59
|
+
if args.json:
|
|
60
|
+
print(json.dumps([it.to_dict() for it in all_items],
|
|
61
|
+
ensure_ascii=False, indent=2))
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
grand = 0
|
|
65
|
+
for key, items in results:
|
|
66
|
+
size = sum(it.size or 0 for it in items)
|
|
67
|
+
grand += size
|
|
68
|
+
icon = icon_by_key.get(key, "•")
|
|
69
|
+
title = title_by_key.get(key, key)
|
|
70
|
+
print(f"{icon} {title}: {len(items)} items · {human_size(size)}")
|
|
71
|
+
print("-" * 40)
|
|
72
|
+
print(f"Total reclaimable: {human_size(grand)} across {len(all_items)} items")
|
|
73
|
+
print("Tip: run `macbroom` for the visual UI, or add --json for machine output.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_version() -> str:
|
|
77
|
+
"""已安装时以包元数据(pyproject 版本)为准,源码运行回退到 __version__。"""
|
|
78
|
+
try:
|
|
79
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
80
|
+
try:
|
|
81
|
+
return version("macbroom")
|
|
82
|
+
except PackageNotFoundError:
|
|
83
|
+
pass
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
from macbroom import __version__
|
|
87
|
+
return __version__
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
parser = argparse.ArgumentParser(description="MacBroom - open-source macOS cleaner")
|
|
92
|
+
parser.add_argument("--version", action="version",
|
|
93
|
+
version=f"macbroom {_resolve_version()}")
|
|
94
|
+
parser.add_argument("--host", default=DEFAULT_HOST)
|
|
95
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
96
|
+
parser.add_argument("--no-open", action="store_true", help="不自动打开浏览器")
|
|
97
|
+
|
|
98
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
99
|
+
p_scan = sub.add_parser("scan", help="在终端扫描并输出报告,不启动服务")
|
|
100
|
+
p_scan.add_argument("--json", action="store_true", help="以 JSON 输出,便于脚本消费")
|
|
101
|
+
p_scan.add_argument("--lang", default="zh", help="zh 或 en")
|
|
102
|
+
p_scan.add_argument("--category", default="",
|
|
103
|
+
help="只扫描指定分类,逗号分隔,如 caches,login_items")
|
|
104
|
+
|
|
105
|
+
p_doctor = sub.add_parser("doctor", help="环境预检:Python/macOS/完全磁盘访问/端口等")
|
|
106
|
+
p_doctor.add_argument("--json", action="store_true", help="JSON 输出")
|
|
107
|
+
p_doctor.add_argument("--lang", default="zh", help="zh 或 en")
|
|
108
|
+
p_doctor.add_argument("--port", type=int, default=DEFAULT_PORT,
|
|
109
|
+
help="待检测的 Web UI 端口(默认 37700)")
|
|
110
|
+
|
|
111
|
+
args = parser.parse_args()
|
|
112
|
+
if args.cmd == "scan":
|
|
113
|
+
_run_scan(args)
|
|
114
|
+
elif args.cmd == "doctor":
|
|
115
|
+
from macbroom.doctor import format_report, run_checks
|
|
116
|
+
checks = run_checks(getattr(args, "port", DEFAULT_PORT))
|
|
117
|
+
if args.json:
|
|
118
|
+
print(json.dumps(checks, ensure_ascii=False, indent=2))
|
|
119
|
+
else:
|
|
120
|
+
print(format_report(checks, normalize_lang(args.lang)))
|
|
121
|
+
if not all(c["ok"] for c in checks):
|
|
122
|
+
raise SystemExit(1)
|
|
123
|
+
else:
|
|
124
|
+
_run_serve(args)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MacBroom core package."""
|
macbroom/core/audit.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""操作审计日志:把每次扫描与删除写到本地文件,可事后追溯。
|
|
2
|
+
|
|
3
|
+
参考 MacSift / MacOS-Maid 的做法:清理工具必须留痕,用户不开调试器也能
|
|
4
|
+
知道「这个工具到底动了什么」。
|
|
5
|
+
|
|
6
|
+
设计要点(对应全局「自测数据安全」硬约束):
|
|
7
|
+
- 日志目录可用环境变量 ``MACBROOM_LOG_DIR`` 覆盖,自测一律指向 /tmp,
|
|
8
|
+
绝不污染用户真实日志目录。
|
|
9
|
+
- 单文件大小封顶,超过就轮转一次(.1),避免无限增长。
|
|
10
|
+
- 仅本地写文件,不发网络,不记录文件内容、仅记录路径与动作。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
_DEFAULT_DIR = os.path.join(os.path.expanduser("~"), "Library", "Logs", "MacBroom")
|
|
21
|
+
_MAX_BYTES = 512 * 1024 # 512KB 封顶,超过轮转
|
|
22
|
+
_LOCK = threading.Lock()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def log_dir() -> str:
|
|
26
|
+
return os.environ.get("MACBROOM_LOG_DIR", _DEFAULT_DIR)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log_path() -> str:
|
|
30
|
+
return os.path.join(log_dir(), "macbroom.log")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _rotate_if_needed(path: str) -> None:
|
|
34
|
+
try:
|
|
35
|
+
if os.path.getsize(path) <= _MAX_BYTES:
|
|
36
|
+
return
|
|
37
|
+
except OSError:
|
|
38
|
+
return
|
|
39
|
+
backup = path + ".1"
|
|
40
|
+
try:
|
|
41
|
+
if os.path.exists(backup):
|
|
42
|
+
os.remove(backup)
|
|
43
|
+
os.rename(path, backup)
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def record(event: str, **fields) -> None:
|
|
49
|
+
"""追加一条结构化日志。失败时静默(日志不应影响主流程)。"""
|
|
50
|
+
line = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "event": event}
|
|
51
|
+
line.update(fields)
|
|
52
|
+
try:
|
|
53
|
+
with _LOCK:
|
|
54
|
+
d = log_dir()
|
|
55
|
+
os.makedirs(d, exist_ok=True)
|
|
56
|
+
path = log_path()
|
|
57
|
+
_rotate_if_needed(path)
|
|
58
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
59
|
+
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
macbroom/core/fsutil.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""文件系统工具:体量计算、安全遍历、受保护路径判定。
|
|
2
|
+
|
|
3
|
+
所有扫描器共用这里的能力,避免各自重写 du / walk 逻辑。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
HOME = os.path.expanduser("~")
|
|
12
|
+
|
|
13
|
+
# iCloud Drive 在本地的根目录。位于其下的文件删除后会同步到所有设备,需高风险对待。
|
|
14
|
+
ICLOUD_ROOT = os.path.join(HOME, "Library", "Mobile Documents", "com~apple~CloudDocs")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_icloud_path(path: str) -> bool:
|
|
18
|
+
"""判断路径是否落在 iCloud 同步范围内。
|
|
19
|
+
|
|
20
|
+
覆盖两种情况:
|
|
21
|
+
1. 直接位于 iCloud Drive(~/Library/Mobile Documents/com~apple~CloudDocs);
|
|
22
|
+
2. 开启「桌面与文档存入 iCloud」后,~/Desktop、~/Documents 实际被 iCloud 接管
|
|
23
|
+
(其 realpath 会指向 Mobile Documents 下)。
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
real = os.path.realpath(path)
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
mobile_docs = os.path.join(HOME, "Library", "Mobile Documents")
|
|
30
|
+
return real == mobile_docs or real.startswith(mobile_docs + os.sep)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 绝不触碰的路径前缀(即便扫描到也不允许作为删除项)。
|
|
34
|
+
PROTECTED_PREFIXES = (
|
|
35
|
+
"/System",
|
|
36
|
+
"/usr",
|
|
37
|
+
"/bin",
|
|
38
|
+
"/sbin",
|
|
39
|
+
"/private/var/db",
|
|
40
|
+
"/Library/Apple",
|
|
41
|
+
os.path.join(HOME, "Library", "Keychains"),
|
|
42
|
+
os.path.join(HOME, ".ssh"),
|
|
43
|
+
os.path.join(HOME, ".gnupg"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 大文件遍历时跳过的目录名(性能 + 噪音控制)。
|
|
47
|
+
SKIP_DIR_NAMES = {
|
|
48
|
+
".git",
|
|
49
|
+
"node_modules",
|
|
50
|
+
".Trash",
|
|
51
|
+
"CoreSimulator", # iOS 模拟器单独扫
|
|
52
|
+
"DerivedData", # iOS 构建产物单独扫
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 大文件遍历时跳过的目录后缀(macOS 的「包」本质是目录)。
|
|
56
|
+
SKIP_DIR_SUFFIXES = (
|
|
57
|
+
".app",
|
|
58
|
+
".framework",
|
|
59
|
+
".photoslibrary",
|
|
60
|
+
".photolibrary",
|
|
61
|
+
".pkg",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def actual_size(st: os.stat_result) -> int:
|
|
66
|
+
"""实际占用磁盘的字节数(按 512B 块计),正确处理稀疏文件。
|
|
67
|
+
|
|
68
|
+
稀疏镜像(OrbStack/VM/磁盘镜像)的 st_size 是逻辑大小,可能远大于真实占用,
|
|
69
|
+
用 st_blocks 才不会虚报可释放空间。
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
return st.st_blocks * 512
|
|
73
|
+
except AttributeError:
|
|
74
|
+
return st.st_size
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def human_size(num: float) -> str:
|
|
78
|
+
"""字节数转可读字符串。"""
|
|
79
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
80
|
+
if abs(num) < 1024.0:
|
|
81
|
+
return f"{num:.1f} {unit}" if unit != "B" else f"{int(num)} B"
|
|
82
|
+
num /= 1024.0
|
|
83
|
+
return f"{num:.1f} PB"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Freedesktop 缓存目录标记(CACHEDIR.TAG)及少数工具使用的 CACHEDIR.txt。
|
|
87
|
+
_CACHE_MARKERS = ("CACHEDIR.TAG", "CACHEDIR.txt")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_marked_cache_dir(path: str) -> bool:
|
|
91
|
+
"""目录内是否带有标准缓存标记文件。"""
|
|
92
|
+
if not path or not os.path.isdir(path):
|
|
93
|
+
return False
|
|
94
|
+
return any(os.path.isfile(os.path.join(path, name)) for name in _CACHE_MARKERS)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_protected(path: str) -> bool:
|
|
98
|
+
real = os.path.realpath(path)
|
|
99
|
+
return any(real == p or real.startswith(p + os.sep) for p in PROTECTED_PREFIXES)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def safe_listdir(path: str) -> list[str]:
|
|
103
|
+
try:
|
|
104
|
+
return os.listdir(path)
|
|
105
|
+
except (PermissionError, FileNotFoundError, NotADirectoryError, OSError):
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def dir_size(path: str, follow_symlinks: bool = False) -> int:
|
|
110
|
+
"""递归计算目录占用,吞掉权限/损坏错误。文件则返回自身大小。"""
|
|
111
|
+
try:
|
|
112
|
+
st = os.lstat(path)
|
|
113
|
+
except OSError:
|
|
114
|
+
return 0
|
|
115
|
+
if os.path.islink(path):
|
|
116
|
+
return 0
|
|
117
|
+
if not os.path.isdir(path):
|
|
118
|
+
return actual_size(st)
|
|
119
|
+
total = 0
|
|
120
|
+
for root, dirs, files in os.walk(path, topdown=True, onerror=lambda e: None,
|
|
121
|
+
followlinks=follow_symlinks):
|
|
122
|
+
for name in files:
|
|
123
|
+
fp = os.path.join(root, name)
|
|
124
|
+
try:
|
|
125
|
+
if not os.path.islink(fp):
|
|
126
|
+
total += actual_size(os.lstat(fp))
|
|
127
|
+
except OSError:
|
|
128
|
+
continue
|
|
129
|
+
return total
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def path_mtime(path: str) -> float:
|
|
133
|
+
try:
|
|
134
|
+
return os.lstat(path).st_mtime
|
|
135
|
+
except OSError:
|
|
136
|
+
return 0.0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def iter_large_files(roots: list[str], min_bytes: int,
|
|
140
|
+
same_device_as: str | None = None) -> Iterator[tuple[str, int, float]]:
|
|
141
|
+
"""遍历 roots 下大于 min_bytes 的普通文件,产出 (path, size, mtime)。
|
|
142
|
+
|
|
143
|
+
会跳过符号链接、受保护路径、SKIP 目录、跨设备挂载点。
|
|
144
|
+
"""
|
|
145
|
+
base_dev = None
|
|
146
|
+
if same_device_as:
|
|
147
|
+
try:
|
|
148
|
+
base_dev = os.lstat(same_device_as).st_dev
|
|
149
|
+
except OSError:
|
|
150
|
+
base_dev = None
|
|
151
|
+
|
|
152
|
+
seen: set[str] = set()
|
|
153
|
+
for root in roots:
|
|
154
|
+
root = os.path.realpath(root)
|
|
155
|
+
if root in seen:
|
|
156
|
+
continue
|
|
157
|
+
seen.add(root)
|
|
158
|
+
for cur, dirs, files in os.walk(root, topdown=True, onerror=lambda e: None):
|
|
159
|
+
# 原地裁剪要跳过的子目录
|
|
160
|
+
pruned = []
|
|
161
|
+
for d in dirs:
|
|
162
|
+
full = os.path.join(cur, d)
|
|
163
|
+
if os.path.islink(full):
|
|
164
|
+
continue
|
|
165
|
+
if d in SKIP_DIR_NAMES or d.endswith(SKIP_DIR_SUFFIXES):
|
|
166
|
+
continue
|
|
167
|
+
if base_dev is not None:
|
|
168
|
+
try:
|
|
169
|
+
if os.lstat(full).st_dev != base_dev:
|
|
170
|
+
continue
|
|
171
|
+
except OSError:
|
|
172
|
+
continue
|
|
173
|
+
pruned.append(d)
|
|
174
|
+
dirs[:] = pruned
|
|
175
|
+
|
|
176
|
+
for name in files:
|
|
177
|
+
fp = os.path.join(cur, name)
|
|
178
|
+
try:
|
|
179
|
+
st = os.lstat(fp)
|
|
180
|
+
except OSError:
|
|
181
|
+
continue
|
|
182
|
+
if os.path.islink(fp):
|
|
183
|
+
continue
|
|
184
|
+
size = actual_size(st)
|
|
185
|
+
if size >= min_bytes:
|
|
186
|
+
yield fp, size, st.st_mtime
|