safaai 0.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.
- safaai/__init__.py +3 -0
- safaai/__main__.py +22 -0
- safaai/actions/__init__.py +1 -0
- safaai/actions/executor.py +201 -0
- safaai/actions/kill.py +33 -0
- safaai/actions/optimize.py +85 -0
- safaai/actions/trash.py +53 -0
- safaai/app.py +59 -0
- safaai/commands/__init__.py +1 -0
- safaai/commands/palette.py +317 -0
- safaai/config.py +36 -0
- safaai/core/__init__.py +30 -0
- safaai/core/debug_log.py +86 -0
- safaai/core/ignore_db.py +108 -0
- safaai/core/journal.py +164 -0
- safaai/core/log_db.py +203 -0
- safaai/core/updater.py +101 -0
- safaai/css/__init__.py +6 -0
- safaai/css/dashboard.py +96 -0
- safaai/css/scan_results.py +54 -0
- safaai/css/screens.py +23 -0
- safaai/css/splash.py +28 -0
- safaai/icon.txt +55 -0
- safaai/scanners/__init__.py +3 -0
- safaai/scanners/base.py +51 -0
- safaai/scanners/brew.py +172 -0
- safaai/scanners/disk.py +117 -0
- safaai/scanners/docker.py +197 -0
- safaai/scanners/graph.py +624 -0
- safaai/scanners/memory.py +84 -0
- safaai/scanners/node.py +167 -0
- safaai/scanners/project_purge.py +111 -0
- safaai/scanners/registry.py +37 -0
- safaai/scanners/startup.py +112 -0
- safaai/scanners/xcode.py +80 -0
- safaai/screens/__init__.py +42 -0
- safaai/screens/admin_password.py +124 -0
- safaai/screens/clean_result.py +120 -0
- safaai/screens/confirm.py +631 -0
- safaai/screens/dashboard.py +38 -0
- safaai/screens/ignore_screen.py +105 -0
- safaai/screens/log_screen.py +462 -0
- safaai/screens/optimize_confirm.py +85 -0
- safaai/screens/progress.py +40 -0
- safaai/screens/scan_results.py +94 -0
- safaai/screens/splash.py +30 -0
- safaai/screens/splash_asciimatics.py +99 -0
- safaai/screens/sysinfo.py +389 -0
- safaai/screens/update_confirm.py +101 -0
- safaai/widgets/broom_audit.py +55 -0
- safaai/widgets/confirm_row.py +342 -0
- safaai/widgets/disk_bento.py +77 -0
- safaai/widgets/health_gauge.py +44 -0
- safaai/widgets/metrics.py +115 -0
- safaai/widgets/suggestions.py +47 -0
- safaai/workflows/__init__.py +1 -0
- safaai/workflows/engine.py +152 -0
- safaai-0.1.0.dist-info/METADATA +118 -0
- safaai-0.1.0.dist-info/RECORD +61 -0
- safaai-0.1.0.dist-info/WHEEL +4 -0
- safaai-0.1.0.dist-info/entry_points.txt +2 -0
safaai/__init__.py
ADDED
safaai/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from safaai.app import SafaaiApp
|
|
3
|
+
from safaai.screens.splash_asciimatics import run_splash_screen
|
|
4
|
+
from safaai.screens.splash import SplashScreen
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
try:
|
|
9
|
+
run_splash_screen()
|
|
10
|
+
SplashScreen.skip_delay = True
|
|
11
|
+
except KeyboardInterrupt:
|
|
12
|
+
sys.exit(0)
|
|
13
|
+
except Exception:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
app = SafaaiApp()
|
|
17
|
+
app.run()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Action executors — safe, previewable, trash-aware operations."""
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Execute cleanup commands safely with timeout and reporting."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
from safaai.core.debug_log import get_logger
|
|
8
|
+
|
|
9
|
+
_admin_session_active: bool = False
|
|
10
|
+
_session_sudo_password: str | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_session_sudo_password(pwd: str | None) -> None:
|
|
14
|
+
"""Set in-memory session sudo password (never stored on disk)."""
|
|
15
|
+
global _session_sudo_password, _admin_session_active
|
|
16
|
+
_session_sudo_password = pwd
|
|
17
|
+
if pwd is not None:
|
|
18
|
+
_admin_session_active = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_session_sudo_password() -> str | None:
|
|
22
|
+
"""Get in-memory session sudo password."""
|
|
23
|
+
return _session_sudo_password
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def verify_sudo_password(pwd: str) -> bool:
|
|
27
|
+
"""Verify sudo password via sudo -S -v."""
|
|
28
|
+
try:
|
|
29
|
+
proc = await asyncio.create_subprocess_exec(
|
|
30
|
+
"sudo", "-S", "-v",
|
|
31
|
+
stdin=asyncio.subprocess.PIPE,
|
|
32
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
33
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
34
|
+
)
|
|
35
|
+
await proc.communicate(input=f"{pwd}\n".encode())
|
|
36
|
+
return proc.returncode == 0
|
|
37
|
+
except (OSError, asyncio.CancelledError):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def request_admin_privileges() -> bool:
|
|
42
|
+
"""Request admin privileges via macOS native dialog.
|
|
43
|
+
|
|
44
|
+
Calls ``sudo -v`` through ``osascript`` so the user gets the standard
|
|
45
|
+
macOS password dialog instead of a hidden terminal prompt. Once
|
|
46
|
+
authenticated the credential stays cached for the default sudo timeout
|
|
47
|
+
(usually 5 minutes).
|
|
48
|
+
|
|
49
|
+
Returns True if the user authenticated, False if they cancelled.
|
|
50
|
+
"""
|
|
51
|
+
global _admin_session_active
|
|
52
|
+
if _session_sudo_password is not None:
|
|
53
|
+
return True
|
|
54
|
+
try:
|
|
55
|
+
proc = await asyncio.create_subprocess_exec(
|
|
56
|
+
"osascript", "-e",
|
|
57
|
+
'do shell script "sudo -v" with administrator privileges',
|
|
58
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
59
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
60
|
+
)
|
|
61
|
+
await proc.communicate()
|
|
62
|
+
_admin_session_active = proc.returncode == 0
|
|
63
|
+
except (OSError, asyncio.CancelledError):
|
|
64
|
+
_admin_session_active = False
|
|
65
|
+
return _admin_session_active
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def admin_session_active() -> bool:
|
|
69
|
+
"""Whether an admin credential is cached for the current session."""
|
|
70
|
+
return bool(_session_sudo_password) or _admin_session_active
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def run_command(cmd: str, timeout: int = 30, sudo_pwd: str | None = None) -> dict:
|
|
74
|
+
"""Run a single shell command with timeout.
|
|
75
|
+
|
|
76
|
+
If sudo_pwd is provided (or session password is set), runs with elevated
|
|
77
|
+
sudo privileges via sudo -S.
|
|
78
|
+
Returns {"command": str, "exit_code": int, "stdout": str, "stderr": str}.
|
|
79
|
+
"""
|
|
80
|
+
logr = get_logger()
|
|
81
|
+
pwd = sudo_pwd or _session_sudo_password
|
|
82
|
+
logr.debug(" run_command: %s (timeout=%ds, elevated=%s)", cmd, timeout, bool(pwd),
|
|
83
|
+
extra={"command": cmd, "timeout": timeout})
|
|
84
|
+
|
|
85
|
+
if pwd and (cmd.startswith("sudo ") or "sudo " in cmd):
|
|
86
|
+
# Convert 'sudo ...' to 'sudo -S ...' for stdin password feeding
|
|
87
|
+
if cmd.startswith("sudo ") and not cmd.startswith("sudo -S "):
|
|
88
|
+
cmd = "sudo -S " + cmd[5:]
|
|
89
|
+
try:
|
|
90
|
+
proc = await asyncio.create_subprocess_shell(
|
|
91
|
+
cmd,
|
|
92
|
+
stdin=asyncio.subprocess.PIPE,
|
|
93
|
+
stdout=asyncio.subprocess.PIPE,
|
|
94
|
+
stderr=asyncio.subprocess.PIPE,
|
|
95
|
+
)
|
|
96
|
+
stdout, stderr = await asyncio.wait_for(
|
|
97
|
+
proc.communicate(input=f"{pwd}\n".encode()), timeout=timeout
|
|
98
|
+
)
|
|
99
|
+
return {
|
|
100
|
+
"command": cmd,
|
|
101
|
+
"exit_code": proc.returncode or 0,
|
|
102
|
+
"stdout": stdout.decode().strip(),
|
|
103
|
+
"stderr": stderr.decode().strip(),
|
|
104
|
+
}
|
|
105
|
+
except asyncio.TimeoutError:
|
|
106
|
+
return {
|
|
107
|
+
"command": cmd,
|
|
108
|
+
"exit_code": -1,
|
|
109
|
+
"stdout": "",
|
|
110
|
+
"stderr": f"Timed out after {timeout}s",
|
|
111
|
+
}
|
|
112
|
+
except OSError as e:
|
|
113
|
+
return {"command": cmd, "exit_code": -1, "stdout": "", "stderr": str(e)}
|
|
114
|
+
|
|
115
|
+
# When no password is supplied, always use non-interactive 'sudo -n'
|
|
116
|
+
# so sudo never touches /dev/tty or corrupts terminal line settings.
|
|
117
|
+
if not pwd and cmd.startswith("sudo ") and not cmd.startswith("sudo -n "):
|
|
118
|
+
cmd = "sudo -n " + cmd[5:]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
proc = await asyncio.create_subprocess_shell(
|
|
122
|
+
cmd,
|
|
123
|
+
stdout=asyncio.subprocess.PIPE,
|
|
124
|
+
stderr=asyncio.subprocess.PIPE,
|
|
125
|
+
)
|
|
126
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
127
|
+
return {
|
|
128
|
+
"command": cmd,
|
|
129
|
+
"exit_code": proc.returncode or 0,
|
|
130
|
+
"stdout": stdout.decode().strip(),
|
|
131
|
+
"stderr": stderr.decode().strip(),
|
|
132
|
+
}
|
|
133
|
+
except asyncio.TimeoutError:
|
|
134
|
+
return {
|
|
135
|
+
"command": cmd,
|
|
136
|
+
"exit_code": -1,
|
|
137
|
+
"stdout": "",
|
|
138
|
+
"stderr": f"Timed out after {timeout}s",
|
|
139
|
+
}
|
|
140
|
+
except OSError as e:
|
|
141
|
+
return {"command": cmd, "exit_code": -1, "stdout": "", "stderr": str(e)}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def run_commands(commands: list[str], timeout: int = 30, sudo_pwd: str | None = None) -> list[dict]:
|
|
145
|
+
"""Run multiple commands sequentially and return results.
|
|
146
|
+
|
|
147
|
+
Commands are run one at a time (not parallel) so output stays ordered.
|
|
148
|
+
"""
|
|
149
|
+
results: list[dict] = []
|
|
150
|
+
for cmd in commands:
|
|
151
|
+
result = await run_command(cmd, timeout=timeout, sudo_pwd=sudo_pwd)
|
|
152
|
+
results.append(result)
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def force_remove_path(path: str, timeout: int = 30, sudo_pwd: str | None = None) -> dict:
|
|
157
|
+
"""Robust force deletion of files/directories.
|
|
158
|
+
|
|
159
|
+
1. Unlocks flags (chflags -R nouchg).
|
|
160
|
+
2. Grants write permissions (chmod -R u+rwX).
|
|
161
|
+
3. Runs rm -rf.
|
|
162
|
+
4. Evaluates if elevated privileges or quarantine fallback are needed.
|
|
163
|
+
"""
|
|
164
|
+
from pathlib import Path
|
|
165
|
+
|
|
166
|
+
pwd = sudo_pwd or _session_sudo_password
|
|
167
|
+
if "*" in path:
|
|
168
|
+
shlex_target = path
|
|
169
|
+
else:
|
|
170
|
+
target = Path(path)
|
|
171
|
+
if not target.exists():
|
|
172
|
+
return {"command": f"rm -rf {path}", "exit_code": 0, "stdout": "", "stderr": "Already deleted"}
|
|
173
|
+
shlex_target = shlex.quote(str(target))
|
|
174
|
+
|
|
175
|
+
await run_command(f"chflags -R nouchg {shlex_target} 2>/dev/null", timeout=10)
|
|
176
|
+
await run_command(f"chmod -R u+rwX {shlex_target} 2>/dev/null", timeout=10)
|
|
177
|
+
|
|
178
|
+
if pwd:
|
|
179
|
+
await run_command(f"sudo -S chflags -R nouchg {shlex_target} 2>/dev/null", timeout=10, sudo_pwd=pwd)
|
|
180
|
+
await run_command(f"sudo -S chmod -R u+rwX {shlex_target} 2>/dev/null", timeout=10, sudo_pwd=pwd)
|
|
181
|
+
res = await run_command(f"sudo -S rm -rf {shlex_target}", timeout=timeout, sudo_pwd=pwd)
|
|
182
|
+
else:
|
|
183
|
+
res = await run_command(f"rm -rf {shlex_target}", timeout=timeout)
|
|
184
|
+
|
|
185
|
+
if "*" not in path and not Path(path).exists():
|
|
186
|
+
return res
|
|
187
|
+
|
|
188
|
+
if res.get("exit_code", 0) == 0:
|
|
189
|
+
return res
|
|
190
|
+
|
|
191
|
+
if "*" not in path and Path(path).exists():
|
|
192
|
+
from safaai.actions.trash import move_to_trash
|
|
193
|
+
status = await move_to_trash(Path(path))
|
|
194
|
+
return {
|
|
195
|
+
"command": f"quarantine {path}",
|
|
196
|
+
"exit_code": 0 if not Path(path).exists() else 1,
|
|
197
|
+
"stdout": status,
|
|
198
|
+
"stderr": "" if not Path(path).exists() else "Quarantine fallback completed",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return res
|
safaai/actions/kill.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Kill user-owned processes safely."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def kill_process(pid: int, force: bool = False) -> str:
|
|
11
|
+
"""Send SIGTERM to a process. Returns status message.
|
|
12
|
+
|
|
13
|
+
When *force* is True, falls back to SIGKILL after 3s timeout.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
proc = psutil.Process(pid)
|
|
17
|
+
name = proc.name() or str(pid)
|
|
18
|
+
except psutil.NoSuchProcess:
|
|
19
|
+
return f"Process {pid} already gone."
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
if force:
|
|
23
|
+
proc.kill()
|
|
24
|
+
return f"Killed {name} (PID {pid})."
|
|
25
|
+
proc.terminate()
|
|
26
|
+
try:
|
|
27
|
+
proc.wait(timeout=3)
|
|
28
|
+
return f"Terminated {name} (PID {pid})."
|
|
29
|
+
except psutil.TimeoutExpired:
|
|
30
|
+
proc.kill()
|
|
31
|
+
return f"Terminated {name} (PID {pid}) — force killed after timeout."
|
|
32
|
+
except psutil.AccessDenied:
|
|
33
|
+
return f"Access denied — cannot kill PID {pid} ({name})."
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""macOS optimization actions — kill user apps + purge RAM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
from safaai.actions.executor import (
|
|
10
|
+
admin_session_active,
|
|
11
|
+
get_session_sudo_password,
|
|
12
|
+
request_admin_privileges,
|
|
13
|
+
run_command,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parent_chain(pid: int) -> set[int]:
|
|
18
|
+
"""Return set of PIDs from *pid* up to launchd (PID 1)."""
|
|
19
|
+
pids = {pid, 1}
|
|
20
|
+
try:
|
|
21
|
+
proc = psutil.Process(pid)
|
|
22
|
+
while True:
|
|
23
|
+
ppid = proc.ppid()
|
|
24
|
+
if ppid <= 1:
|
|
25
|
+
break
|
|
26
|
+
pids.add(ppid)
|
|
27
|
+
proc = psutil.Process(ppid)
|
|
28
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
29
|
+
pass
|
|
30
|
+
return pids
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def kill_user_apps() -> dict:
|
|
34
|
+
"""Kill all third-party user processes, keeping only system + safaai.
|
|
35
|
+
|
|
36
|
+
System = root-owned + /System/ binaries + launchd.
|
|
37
|
+
Safaai's own PID and its terminal/SSH parent chain are also protected.
|
|
38
|
+
"""
|
|
39
|
+
my_pid = os.getpid()
|
|
40
|
+
protected_pids = _parent_chain(my_pid)
|
|
41
|
+
protected_pids.add(my_pid)
|
|
42
|
+
|
|
43
|
+
targets: list[psutil.Process] = []
|
|
44
|
+
|
|
45
|
+
for proc in psutil.process_iter(["pid", "name", "username", "exe"]):
|
|
46
|
+
try:
|
|
47
|
+
pid = proc.info["pid"]
|
|
48
|
+
if pid in protected_pids:
|
|
49
|
+
continue
|
|
50
|
+
if proc.info.get("username") == "root":
|
|
51
|
+
continue
|
|
52
|
+
exe = (proc.info.get("exe") or "")
|
|
53
|
+
if exe.startswith("/System/") or exe.startswith("/usr/libexec/"):
|
|
54
|
+
continue
|
|
55
|
+
targets.append(proc)
|
|
56
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
killed_names: list[str] = []
|
|
60
|
+
for p in targets:
|
|
61
|
+
try:
|
|
62
|
+
p.terminate()
|
|
63
|
+
killed_names.append(p.name() or str(p.pid))
|
|
64
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
# Grace period then force-kill survivors
|
|
68
|
+
gone, alive = psutil.wait_procs(targets, timeout=3)
|
|
69
|
+
for p in alive:
|
|
70
|
+
try:
|
|
71
|
+
p.kill()
|
|
72
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
return {"exit_code": 0, "killed": len(killed_names), "stdout": f"Killed {len(killed_names)} apps"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def purge_memory() -> dict:
|
|
79
|
+
"""Run macOS ``purge`` to release inactive memory pages (needs admin)."""
|
|
80
|
+
if not admin_session_active():
|
|
81
|
+
ok = await request_admin_privileges()
|
|
82
|
+
if not ok:
|
|
83
|
+
return {"command": "purge", "exit_code": -1, "stdout": "", "stderr": "Admin access denied"}
|
|
84
|
+
sudo_pwd = get_session_sudo_password()
|
|
85
|
+
return await run_command("sudo purge", sudo_pwd=sudo_pwd)
|
safaai/actions/trash.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Move paths to system Trash or Quarantine instead of risky delete."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def move_to_trash(path: str | Path) -> str:
|
|
12
|
+
"""Move a file or directory to macOS Trash via osascript.
|
|
13
|
+
|
|
14
|
+
Falls back to ~/.Trash and then ~/.local/share/safaai/quarantine/
|
|
15
|
+
Returns a status message.
|
|
16
|
+
"""
|
|
17
|
+
path = Path(path)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return f"Path does not exist: {path}"
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
escaped = shlex.quote(str(path.resolve()))
|
|
23
|
+
script = f'tell app "Finder" to delete POSIX file {escaped}'
|
|
24
|
+
proc = await asyncio.create_subprocess_exec(
|
|
25
|
+
"osascript", "-e", script,
|
|
26
|
+
stdout=asyncio.subprocess.PIPE,
|
|
27
|
+
stderr=asyncio.subprocess.PIPE,
|
|
28
|
+
)
|
|
29
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
30
|
+
if proc.returncode == 0:
|
|
31
|
+
return f"Moved to Trash: {path}"
|
|
32
|
+
except (OSError, asyncio.TimeoutError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Fallback 1: move to ~/.Trash/
|
|
36
|
+
trash_dir = Path.home() / ".Trash"
|
|
37
|
+
if trash_dir.exists():
|
|
38
|
+
trash_target = trash_dir / f"{path.name}_{int(time.time())}"
|
|
39
|
+
try:
|
|
40
|
+
await asyncio.to_thread(shutil.move, str(path), str(trash_target))
|
|
41
|
+
return f"Moved to Trash (fallback): {path}"
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# Fallback 2: Safe Quarantine directory
|
|
46
|
+
quarantine_dir = Path.home() / ".local" / "share" / "safaai" / "quarantine"
|
|
47
|
+
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
quarantine_target = quarantine_dir / f"{int(time.time())}_{path.name}"
|
|
49
|
+
try:
|
|
50
|
+
await asyncio.to_thread(shutil.move, str(path), str(quarantine_target))
|
|
51
|
+
return f"Quarantined safely: {path} → {quarantine_target}"
|
|
52
|
+
except OSError as e:
|
|
53
|
+
return f"Failed to trash or quarantine {path}: {e}"
|
safaai/app.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""SafaaiApp — root application."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
from textual.app import App
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
|
|
9
|
+
from safaai.commands.palette import SafaaiCommandProvider
|
|
10
|
+
from safaai.css import APP_CSS
|
|
11
|
+
from safaai.scanners.brew import BrewScanner
|
|
12
|
+
from safaai.scanners.disk import DiskScanner
|
|
13
|
+
from safaai.scanners.docker import DockerScanner
|
|
14
|
+
from safaai.scanners.graph import GraphScanner
|
|
15
|
+
from safaai.scanners.memory import MemoryScanner
|
|
16
|
+
from safaai.scanners.node import NodeScanner
|
|
17
|
+
from safaai.scanners.project_purge import ProjectPurgeScanner
|
|
18
|
+
from safaai.scanners.registry import get_registry
|
|
19
|
+
from safaai.scanners.startup import StartupScanner
|
|
20
|
+
from safaai.scanners.xcode import XcodeScanner
|
|
21
|
+
from safaai.screens.dashboard import DashboardScreen
|
|
22
|
+
from safaai.screens.splash import SplashScreen
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SafaaiApp(App):
|
|
26
|
+
"""Safaai TUI application."""
|
|
27
|
+
|
|
28
|
+
TITLE = "safaai"
|
|
29
|
+
CSS = APP_CSS
|
|
30
|
+
SCREENS = {
|
|
31
|
+
"dashboard": DashboardScreen,
|
|
32
|
+
"splash": SplashScreen,
|
|
33
|
+
}
|
|
34
|
+
COMMANDS = {SafaaiCommandProvider}
|
|
35
|
+
|
|
36
|
+
BINDINGS = [
|
|
37
|
+
Binding("q", "quit", "Quit", priority=True),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def on_mount(self) -> None:
|
|
41
|
+
# Register built-in scanners
|
|
42
|
+
get_registry().register(DiskScanner())
|
|
43
|
+
get_registry().register(DockerScanner())
|
|
44
|
+
get_registry().register(NodeScanner())
|
|
45
|
+
get_registry().register(BrewScanner())
|
|
46
|
+
get_registry().register(XcodeScanner())
|
|
47
|
+
get_registry().register(StartupScanner())
|
|
48
|
+
get_registry().register(MemoryScanner())
|
|
49
|
+
get_registry().register(GraphScanner())
|
|
50
|
+
get_registry().register(ProjectPurgeScanner())
|
|
51
|
+
# Start on splash
|
|
52
|
+
self.push_screen("splash")
|
|
53
|
+
|
|
54
|
+
def on_exit(self) -> None:
|
|
55
|
+
"""Cancel any still-running background tasks on quit so subprocesses
|
|
56
|
+
are not orphaned."""
|
|
57
|
+
for task in asyncio.all_tasks():
|
|
58
|
+
if task is not asyncio.current_task():
|
|
59
|
+
task.cancel()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Slash command providers for Textual command palette."""
|