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 ADDED
@@ -0,0 +1,5 @@
1
+ """MacBroom —— 本地、安全、可视化的 macOS 清理工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.1.0"
macbroom/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """支持 ``python -m macbroom`` 启动。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from macbroom.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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
@@ -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