wuti-py-macos 0.2.8__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.
- wuti_py_macos-0.2.8/LICENSE +21 -0
- wuti_py_macos-0.2.8/PKG-INFO +23 -0
- wuti_py_macos-0.2.8/pyproject.toml +99 -0
- wuti_py_macos-0.2.8/src/wuti/macos/__init__.py +29 -0
- wuti_py_macos-0.2.8/src/wuti/macos/cleanup.py +187 -0
- wuti_py_macos-0.2.8/src/wuti/macos/constants.py +15 -0
- wuti_py_macos-0.2.8/src/wuti/macos/daemon.py +280 -0
- wuti_py_macos-0.2.8/src/wuti/macos/dock.py +85 -0
- wuti_py_macos-0.2.8/src/wuti/macos/finder.py +489 -0
- wuti_py_macos-0.2.8/src/wuti/macos/pasteboard.py +18 -0
- wuti_py_macos-0.2.8/src/wuti/macos/pkgutil.py +72 -0
- wuti_py_macos-0.2.8/src/wuti/macos/utils.py +215 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WUTI
|
|
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,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wuti-py-macos
|
|
3
|
+
Version: 0.2.8
|
|
4
|
+
Summary: Clean, Pythonic utilities for macOS system operations
|
|
5
|
+
Project-URL: Homepage, https://github.com/wuti-labs
|
|
6
|
+
Author-email: Alex Qin <qinqinghai@hotmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: daemon,dock,finder,macos,pasteboard,system,utility
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Requires-Dist: wuti-py-util>=0.6.0
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wuti-py-macos"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Clean, Pythonic utilities for macOS system operations"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Alex Qin", email = "qinqinghai@hotmail.com" }]
|
|
12
|
+
keywords = ["macos", "system", "utility", "pasteboard", "finder", "dock", "daemon"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: MacOS :: MacOS X",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"wuti-py-util>=0.6.0",
|
|
28
|
+
"pydantic>=2.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/wuti-labs"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"build",
|
|
38
|
+
"coverage[toml]>=7.13",
|
|
39
|
+
"hatchling",
|
|
40
|
+
"mypy>=1.18",
|
|
41
|
+
"pytest>=9.0.2",
|
|
42
|
+
"pytest-cov>=7.0",
|
|
43
|
+
"ruff>=0.15",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.version]
|
|
47
|
+
path = "src/wuti/macos/__init__.py"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/wuti"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
ignore-vcs = true
|
|
54
|
+
only-include = ["src/wuti"]
|
|
55
|
+
exclude = [
|
|
56
|
+
"/.github",
|
|
57
|
+
"/tests",
|
|
58
|
+
"/README.md",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.sdist.hooks.custom]
|
|
62
|
+
path = "scripts/hatch_sdist_hook.py"
|
|
63
|
+
|
|
64
|
+
[tool.ruff]
|
|
65
|
+
line-length = 140
|
|
66
|
+
target-version = "py310"
|
|
67
|
+
src = ["src", "tests"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E4", "E7", "E9", "F", "B"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"tests/**" = ["B017", "F841"]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
python_version = "3.10"
|
|
77
|
+
files = ["src"]
|
|
78
|
+
mypy_path = "src"
|
|
79
|
+
explicit_package_bases = true
|
|
80
|
+
namespace_packages = true
|
|
81
|
+
ignore_missing_imports = true
|
|
82
|
+
warn_unused_configs = true
|
|
83
|
+
check_untyped_defs = true
|
|
84
|
+
allow_redefinition = true
|
|
85
|
+
|
|
86
|
+
[tool.pytest.ini_options]
|
|
87
|
+
pythonpath = ["src"]
|
|
88
|
+
testpaths = ["tests"]
|
|
89
|
+
addopts = "-v --cov=src --cov-branch --cov-report=html --cov-report=term --cov-report=term-missing"
|
|
90
|
+
|
|
91
|
+
[tool.coverage.run]
|
|
92
|
+
source = ["src"]
|
|
93
|
+
omit = ["*/tests/*"]
|
|
94
|
+
branch = true
|
|
95
|
+
relative_files = true
|
|
96
|
+
|
|
97
|
+
[tool.coverage.report]
|
|
98
|
+
exclude_lines = ["pragma: no cover", "def __repr__"]
|
|
99
|
+
fail_under = 100
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""wuti-py-macos: A clean and simple Python library for macOS system operations.
|
|
2
|
+
|
|
3
|
+
This package provides utilities for managing various macOS system components
|
|
4
|
+
including Finder, Dock, Launchpad, daemons, pasteboard, and more.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import (
|
|
8
|
+
cleanup,
|
|
9
|
+
daemon,
|
|
10
|
+
dock,
|
|
11
|
+
finder,
|
|
12
|
+
pasteboard,
|
|
13
|
+
pkgutil,
|
|
14
|
+
utils,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.8"
|
|
18
|
+
__author__ = "Alex Qin"
|
|
19
|
+
__email__ = "qinqinghai@hotmail.com"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"cleanup",
|
|
23
|
+
"daemon",
|
|
24
|
+
"dock",
|
|
25
|
+
"finder",
|
|
26
|
+
"pasteboard",
|
|
27
|
+
"pkgutil",
|
|
28
|
+
"utils",
|
|
29
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Module for system file and cache cleanup on macOS.
|
|
2
|
+
|
|
3
|
+
This module provides functions for cleaning up:
|
|
4
|
+
- Desktop Service Store (.DS_Store) files
|
|
5
|
+
- Icon cache files
|
|
6
|
+
- Icon services cache
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from wuti.util.log import notice, warning
|
|
15
|
+
|
|
16
|
+
from .constants import ICON_SERVICES_CACHE_PATH
|
|
17
|
+
from .finder import Finder, get_system_paths
|
|
18
|
+
from .utils import CommandRunner, handle_keyboard_interrupt, handle_runtime_error
|
|
19
|
+
|
|
20
|
+
# Cleanup patterns
|
|
21
|
+
DS_STORE_PATTERNS = [".DS_Store", "**/.DS_Store"]
|
|
22
|
+
ICONCACHE_PATTERNS = ["**/com.apple.dock.iconcache"]
|
|
23
|
+
ICONSERVICES_PATTERNS = ["**/com.apple.iconservices"]
|
|
24
|
+
|
|
25
|
+
_CLEANUP_DS_STORE_OPERATION = "cleanup Desktop Service Store files"
|
|
26
|
+
|
|
27
|
+
_finder_instance: Optional[Finder] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PruningMetrics:
|
|
32
|
+
removed_files: int = 0
|
|
33
|
+
removed_dirs: int = 0
|
|
34
|
+
|
|
35
|
+
def print_summary(self) -> None:
|
|
36
|
+
notice(f"Cleaned {self.removed_files} Desktop Service Store files; removed {self.removed_dirs} empty directories.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _delete_ds_store_files(root: str, stats: PruningMetrics) -> None:
|
|
40
|
+
"""Remove ``.DS_Store`` under *root* (top-down: shallow first)."""
|
|
41
|
+
for dirpath, _dirs, filenames in os.walk(root, topdown=True, followlinks=False):
|
|
42
|
+
if ".DS_Store" not in filenames:
|
|
43
|
+
continue
|
|
44
|
+
target = os.path.join(dirpath, ".DS_Store")
|
|
45
|
+
try:
|
|
46
|
+
os.remove(target)
|
|
47
|
+
stats.removed_files += 1
|
|
48
|
+
except OSError as e:
|
|
49
|
+
warning(f"Failed to remove '{target}': {e}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rmdir_empty_dirs_under(root: str, stats: PruningMetrics) -> None:
|
|
53
|
+
"""Remove empty directories under *root*, excluding *root* itself (bottom-up)."""
|
|
54
|
+
for dirpath, _, _ in os.walk(root, topdown=False, followlinks=False):
|
|
55
|
+
if os.path.realpath(dirpath) == root:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
if os.listdir(dirpath):
|
|
59
|
+
continue
|
|
60
|
+
os.rmdir(dirpath)
|
|
61
|
+
stats.removed_dirs += 1
|
|
62
|
+
except OSError as e:
|
|
63
|
+
warning(f"Failed to rmdir '{dirpath}': {e}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_finder() -> Finder:
|
|
67
|
+
"""Get or create a cached Finder instance.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Finder instance for file operations.
|
|
71
|
+
"""
|
|
72
|
+
global _finder_instance
|
|
73
|
+
if _finder_instance is None:
|
|
74
|
+
_finder_instance = Finder()
|
|
75
|
+
return _finder_instance
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _private_var_folders() -> str:
|
|
79
|
+
"""Path under which icon cache / icon services cleanup runs."""
|
|
80
|
+
return get_system_paths()["private_var_folders"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@handle_runtime_error(_CLEANUP_DS_STORE_OPERATION)
|
|
84
|
+
def cleanup_ds_store(*, remove_empty_dirs: bool = False) -> Tuple[int, int]:
|
|
85
|
+
"""Clean up macOS Desktop Service Store files.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
remove_empty_dirs: If True, remove directories that become empty after .DS_Store removal
|
|
89
|
+
(excluding the scan root). If False, only delete .DS_Store files.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple of (number of .DS_Store files removed, number of empty directories removed).
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
RuntimeError: If the cleanup operation fails.
|
|
96
|
+
KeyboardInterrupt: Re-raised after printing a summary of progress so far.
|
|
97
|
+
|
|
98
|
+
Note:
|
|
99
|
+
On Ctrl+C, prints the same summary line as on normal completion, then re-raises.
|
|
100
|
+
"""
|
|
101
|
+
root = os.path.abspath(".")
|
|
102
|
+
stats = PruningMetrics()
|
|
103
|
+
|
|
104
|
+
def _on_interrupt(_: str) -> None:
|
|
105
|
+
stats.print_summary()
|
|
106
|
+
|
|
107
|
+
@handle_keyboard_interrupt(_CLEANUP_DS_STORE_OPERATION, on_interrupt=_on_interrupt)
|
|
108
|
+
def _run_phases() -> None:
|
|
109
|
+
_delete_ds_store_files(root, stats)
|
|
110
|
+
if remove_empty_dirs:
|
|
111
|
+
_rmdir_empty_dirs_under(root, stats)
|
|
112
|
+
|
|
113
|
+
_run_phases()
|
|
114
|
+
stats.print_summary()
|
|
115
|
+
return (stats.removed_files, stats.removed_dirs)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def superuser_clean_icon_services() -> None:
|
|
119
|
+
"""Delete the icon services cache (requires superuser privileges).
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
subprocess.CalledProcessError: If the rm command fails.
|
|
123
|
+
RuntimeError: If icon services cache cannot be cleaned.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
CommandRunner.run(["rm", "-rf", ICON_SERVICES_CACHE_PATH], check=True)
|
|
127
|
+
notice("Icon services cache has been cleaned (superuser).")
|
|
128
|
+
except subprocess.CalledProcessError as e:
|
|
129
|
+
raise RuntimeError(f"Failed to clean icon services cache: {e.stderr}") from e
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise RuntimeError(f"Failed to clean icon services cache: {e}") from e
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@handle_runtime_error("clean icon cache")
|
|
135
|
+
def superuser_clean_icon_cache() -> None:
|
|
136
|
+
"""Delete the icon caches (requires superuser privileges).
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
RuntimeError: If icon cache cannot be cleaned.
|
|
140
|
+
"""
|
|
141
|
+
finder = _get_finder()
|
|
142
|
+
finder.cleanup_recursive(_private_var_folders(), ICONCACHE_PATTERNS)
|
|
143
|
+
notice("Icon cache has been cleaned (superuser).")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@handle_runtime_error("find icon cache folders")
|
|
147
|
+
def find_icon_cache() -> list[str]:
|
|
148
|
+
"""Find the icon cache folders.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of icon cache folder paths.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
RuntimeError: If icon cache folders cannot be found.
|
|
155
|
+
"""
|
|
156
|
+
finder = _get_finder()
|
|
157
|
+
return finder.find_recursive(_private_var_folders(), ICONCACHE_PATTERNS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@handle_runtime_error("clean icon services cache")
|
|
161
|
+
def clean_icon_services() -> int:
|
|
162
|
+
"""Delete the icon services cache.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Number of icon service items cleaned.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
RuntimeError: If icon services cache cannot be cleaned.
|
|
169
|
+
"""
|
|
170
|
+
finder = _get_finder()
|
|
171
|
+
cleanup_count = finder.cleanup_recursive(_private_var_folders(), ICONSERVICES_PATTERNS)
|
|
172
|
+
notice(f"Cleaned {cleanup_count} icon service items.")
|
|
173
|
+
return cleanup_count
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@handle_runtime_error("find icon services cache folders")
|
|
177
|
+
def find_icon_services() -> list[str]:
|
|
178
|
+
"""Find the icon services cache folders.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of icon services cache folder paths.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
RuntimeError: If icon services cache folders cannot be found.
|
|
185
|
+
"""
|
|
186
|
+
finder = _get_finder()
|
|
187
|
+
return finder.find_recursive(_private_var_folders(), ICONSERVICES_PATTERNS)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared constants for wuti.macos."""
|
|
2
|
+
|
|
3
|
+
# Process names
|
|
4
|
+
FINDER = "Finder"
|
|
5
|
+
DOCK = "Dock"
|
|
6
|
+
|
|
7
|
+
# Preference domains
|
|
8
|
+
DOMAIN_DOCK = "com.apple.dock"
|
|
9
|
+
DOMAIN_FINDER = "com.apple.finder"
|
|
10
|
+
|
|
11
|
+
# Delays
|
|
12
|
+
APP_RESTART_DELAY = 0.2
|
|
13
|
+
|
|
14
|
+
# Paths
|
|
15
|
+
ICON_SERVICES_CACHE_PATH = "/Library/Caches/com.apple.iconservices.store"
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Module for managing daemons and agents on macOS."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from wuti.util.log import debug, indent, notice, unindent, warning
|
|
9
|
+
from wuti.util.validation import validate_string_input
|
|
10
|
+
|
|
11
|
+
from .finder import Finder, get_daemon_paths
|
|
12
|
+
from .utils import CommandRunner, handle_runtime_error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Daemon:
|
|
16
|
+
"""Manages daemons and agents on macOS.
|
|
17
|
+
|
|
18
|
+
Provides comprehensive methods to inspect, manage, and control macOS daemons and agents
|
|
19
|
+
through their configuration files in various LaunchDaemons and LaunchAgents directories.
|
|
20
|
+
|
|
21
|
+
This class offers functionality to:
|
|
22
|
+
- List all daemons and agents
|
|
23
|
+
- Search for specific daemons/agents
|
|
24
|
+
- Get detailed information about daemon configurations
|
|
25
|
+
- Control daemon states (load/unload)
|
|
26
|
+
- Filter and categorize daemons by type
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
"""Initialize the Daemon instance."""
|
|
31
|
+
debug("Initializing Daemon instance")
|
|
32
|
+
|
|
33
|
+
@cached_property
|
|
34
|
+
def finder(self) -> Finder:
|
|
35
|
+
"""The finder object for file operations.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Finder: An instance for file operations.
|
|
39
|
+
"""
|
|
40
|
+
return Finder()
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def daemon_paths(self) -> dict[str, str]:
|
|
44
|
+
"""Daemon and agent paths.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
dict[str, str]: A dictionary mapping path names to their absolute paths.
|
|
48
|
+
"""
|
|
49
|
+
return get_daemon_paths()
|
|
50
|
+
|
|
51
|
+
@handle_runtime_error("print daemon and agent information")
|
|
52
|
+
def print_all(self) -> None:
|
|
53
|
+
"""Prints all daemons and agents from all configured paths.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If any daemon or agent directory cannot be accessed.
|
|
57
|
+
"""
|
|
58
|
+
debug("Printing all daemons and agents")
|
|
59
|
+
for path_name, path in self.daemon_paths.items():
|
|
60
|
+
notice(f"=== {path_name.replace('_', ' ').title()} ===")
|
|
61
|
+
try:
|
|
62
|
+
self.finder.print_flat_list(path)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
warning(f"Failed to access {path_name} at '{path}': {e}")
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
def get_daemon_files(self, path_name: Optional[str] = None) -> list[str]:
|
|
68
|
+
"""Get all daemon/agent plist files from specified or all paths.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path_name: Specific path name to search in. If None, searches all paths.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
list[str]: List of plist file paths.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If path_name is invalid.
|
|
78
|
+
RuntimeError: If file listing fails.
|
|
79
|
+
"""
|
|
80
|
+
debug(f"Getting daemon files from path: {path_name or 'all'}")
|
|
81
|
+
|
|
82
|
+
all_files = []
|
|
83
|
+
|
|
84
|
+
if path_name:
|
|
85
|
+
if path_name not in self.daemon_paths:
|
|
86
|
+
raise ValueError(f"Invalid path name '{path_name}'. Available paths: {list(self.daemon_paths.keys())}")
|
|
87
|
+
paths_to_search = {path_name: self.daemon_paths[path_name]}
|
|
88
|
+
else:
|
|
89
|
+
paths_to_search = self.daemon_paths
|
|
90
|
+
|
|
91
|
+
for path_name, path in paths_to_search.items():
|
|
92
|
+
try:
|
|
93
|
+
if os.path.exists(path):
|
|
94
|
+
plist_files = glob.glob(os.path.join(path, "*.plist"))
|
|
95
|
+
all_files.extend(plist_files)
|
|
96
|
+
debug(f"Found {len(plist_files)} plist files in {path_name}")
|
|
97
|
+
else:
|
|
98
|
+
warning(f"Path does not exist: {path}")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
warning(f"Failed to list files in {path_name} at '{path}': {e}")
|
|
101
|
+
|
|
102
|
+
return sorted(all_files)
|
|
103
|
+
|
|
104
|
+
def search_daemons(self, pattern: str, path_name: Optional[str] = None) -> list[str]:
|
|
105
|
+
"""Search for daemons/agents matching a pattern.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
pattern: Search pattern to match against plist filenames.
|
|
109
|
+
path_name: Specific path name to search in. If None, searches all paths.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
list[str]: List of matching plist file paths.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If pattern is empty or path_name is invalid.
|
|
116
|
+
"""
|
|
117
|
+
pattern = validate_string_input(pattern, "pattern")
|
|
118
|
+
debug(f"Searching for daemons matching pattern: '{pattern}'")
|
|
119
|
+
|
|
120
|
+
all_files = self.get_daemon_files(path_name)
|
|
121
|
+
matching_files = [f for f in all_files if pattern.lower() in os.path.basename(f).lower()]
|
|
122
|
+
|
|
123
|
+
debug(f"Found {len(matching_files)} matching files")
|
|
124
|
+
return matching_files
|
|
125
|
+
|
|
126
|
+
def get_daemon_info(self, plist_path: str) -> dict[str, str | bool | int | float | None]:
|
|
127
|
+
"""Get detailed information about a daemon/agent from its plist file.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
plist_path: Path to the plist file.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
dict containing daemon information including:
|
|
134
|
+
- name: Daemon name
|
|
135
|
+
- path: Full path to plist
|
|
136
|
+
- exists: Whether the file exists
|
|
137
|
+
- size: File size in bytes
|
|
138
|
+
- modified: Last modification time
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If plist_path is invalid.
|
|
142
|
+
"""
|
|
143
|
+
plist_path = validate_string_input(plist_path, "plist_path")
|
|
144
|
+
debug(f"Getting daemon info for: {plist_path}")
|
|
145
|
+
|
|
146
|
+
info: dict[str, str | bool | int | float | None] = {
|
|
147
|
+
"name": os.path.basename(plist_path),
|
|
148
|
+
"path": plist_path,
|
|
149
|
+
"exists": False,
|
|
150
|
+
"size": 0,
|
|
151
|
+
"modified": None,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
if os.path.exists(plist_path):
|
|
156
|
+
stat_info = os.stat(plist_path)
|
|
157
|
+
info.update({"exists": True, "size": stat_info.st_size, "modified": stat_info.st_mtime})
|
|
158
|
+
except OSError as e:
|
|
159
|
+
warning(f"Failed to get info for {plist_path}: {e}")
|
|
160
|
+
|
|
161
|
+
return info
|
|
162
|
+
|
|
163
|
+
def get_daemon_status(self, daemon_name: str) -> dict[str, str | bool]:
|
|
164
|
+
"""Get the current status of a daemon/agent.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
daemon_name: Name of the daemon (without .plist extension).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
dict containing status information:
|
|
171
|
+
- loaded: Whether the daemon is currently loaded
|
|
172
|
+
- status: Human-readable status string
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If daemon_name is invalid.
|
|
176
|
+
"""
|
|
177
|
+
daemon_name = validate_string_input(daemon_name, "daemon_name")
|
|
178
|
+
debug(f"Getting status for daemon: {daemon_name}")
|
|
179
|
+
|
|
180
|
+
# Remove .plist extension if present
|
|
181
|
+
if daemon_name.endswith(".plist"):
|
|
182
|
+
daemon_name = daemon_name[:-6]
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# Use launchctl to check if daemon is loaded
|
|
186
|
+
result = CommandRunner.run(["launchctl", "list", daemon_name], check=False)
|
|
187
|
+
loaded = result.returncode == 0 and daemon_name in result.stdout
|
|
188
|
+
|
|
189
|
+
status = "Loaded" if loaded else "Not loaded"
|
|
190
|
+
|
|
191
|
+
return {"loaded": loaded, "status": status, "name": daemon_name}
|
|
192
|
+
except Exception as e:
|
|
193
|
+
warning(f"Failed to get status for daemon {daemon_name}: {e}")
|
|
194
|
+
return {"loaded": False, "status": f"Error: {e}", "name": daemon_name}
|
|
195
|
+
|
|
196
|
+
def load_daemon(self, plist_path: str) -> bool:
|
|
197
|
+
"""Load a daemon/agent using launchctl.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
plist_path: Path to the plist file.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
bool: True if successful, False otherwise.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ValueError: If plist_path is invalid.
|
|
207
|
+
"""
|
|
208
|
+
plist_path = validate_string_input(plist_path, "plist_path")
|
|
209
|
+
debug(f"Loading daemon from: {plist_path}")
|
|
210
|
+
|
|
211
|
+
if not os.path.exists(plist_path):
|
|
212
|
+
warning(f"Plist file does not exist: {plist_path}")
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
CommandRunner.run(["launchctl", "load", plist_path])
|
|
217
|
+
notice(f"Successfully loaded daemon: {os.path.basename(plist_path)}")
|
|
218
|
+
return True
|
|
219
|
+
except Exception as e:
|
|
220
|
+
warning(f"Failed to load daemon {plist_path}: {e}")
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
def unload_daemon(self, plist_path: str) -> bool:
|
|
224
|
+
"""Unload a daemon/agent using launchctl.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
plist_path: Path to the plist file.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
bool: True if successful, False otherwise.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
ValueError: If plist_path is invalid.
|
|
234
|
+
"""
|
|
235
|
+
plist_path = validate_string_input(plist_path, "plist_path")
|
|
236
|
+
debug(f"Unloading daemon from: {plist_path}")
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
CommandRunner.run(["launchctl", "unload", plist_path])
|
|
240
|
+
notice(f"Successfully unloaded daemon: {os.path.basename(plist_path)}")
|
|
241
|
+
return True
|
|
242
|
+
except Exception as e:
|
|
243
|
+
warning(f"Failed to unload daemon {plist_path}: {e}")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def get_daemon_summary(self) -> dict[str, int]:
|
|
247
|
+
"""Get a summary count of daemons and agents by path.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
dict[str, int]: Dictionary mapping path names to file counts.
|
|
251
|
+
"""
|
|
252
|
+
debug("Generating daemon summary")
|
|
253
|
+
summary = {}
|
|
254
|
+
|
|
255
|
+
for path_name, path in self.daemon_paths.items():
|
|
256
|
+
try:
|
|
257
|
+
if os.path.exists(path):
|
|
258
|
+
plist_files = glob.glob(os.path.join(path, "*.plist"))
|
|
259
|
+
summary[path_name] = len(plist_files)
|
|
260
|
+
else:
|
|
261
|
+
summary[path_name] = 0
|
|
262
|
+
except Exception as e:
|
|
263
|
+
warning(f"Failed to count files in {path_name}: {e}")
|
|
264
|
+
summary[path_name] = 0
|
|
265
|
+
|
|
266
|
+
return summary
|
|
267
|
+
|
|
268
|
+
def print_summary(self) -> None:
|
|
269
|
+
"""Print a summary of all daemons and agents by path."""
|
|
270
|
+
debug("Printing daemon summary")
|
|
271
|
+
summary = self.get_daemon_summary()
|
|
272
|
+
|
|
273
|
+
notice("=== Daemon and Agent Summary ===")
|
|
274
|
+
indent()
|
|
275
|
+
total = 0
|
|
276
|
+
for path_name, count in summary.items():
|
|
277
|
+
notice(f"{path_name.replace('_', ' ').title()}: {count} files")
|
|
278
|
+
total += count
|
|
279
|
+
unindent()
|
|
280
|
+
notice(f"Total: {total} daemon/agent files")
|