nexo-brain 5.3.19 → 5.3.21
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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/nexo-brain.js +52 -10
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""Runtime power policy helpers.
|
|
3
|
+
|
|
4
|
+
Manages the optional "prevent sleep" helper as an explicit, persisted runtime
|
|
5
|
+
preference. The policy is stored in config/schedule.json to avoid introducing a
|
|
6
|
+
second user-facing config surface.
|
|
7
|
+
|
|
8
|
+
Important semantic note:
|
|
9
|
+
- ``always_on`` means "enable the platform power helper" for best-effort
|
|
10
|
+
background availability.
|
|
11
|
+
- It does not replace wake recovery or catchup.
|
|
12
|
+
- On laptops, especially with the lid closed, behavior remains platform and
|
|
13
|
+
setup dependent.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import plistlib
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
27
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
28
|
+
CONFIG_DIR = NEXO_HOME / "config"
|
|
29
|
+
SCHEDULE_FILE = CONFIG_DIR / "schedule.json"
|
|
30
|
+
POWER_POLICY_KEY = "power_policy"
|
|
31
|
+
POWER_POLICY_VERSION_KEY = "power_policy_version"
|
|
32
|
+
POWER_POLICY_VERSION = 2
|
|
33
|
+
POWER_POLICY_ALWAYS_ON = "always_on"
|
|
34
|
+
POWER_POLICY_DISABLED = "disabled"
|
|
35
|
+
POWER_POLICY_UNSET = "unset"
|
|
36
|
+
VALID_POWER_POLICIES = {
|
|
37
|
+
POWER_POLICY_ALWAYS_ON,
|
|
38
|
+
POWER_POLICY_DISABLED,
|
|
39
|
+
POWER_POLICY_UNSET,
|
|
40
|
+
}
|
|
41
|
+
FULL_DISK_ACCESS_STATUS_KEY = "full_disk_access_status"
|
|
42
|
+
FULL_DISK_ACCESS_STATUS_VERSION_KEY = "full_disk_access_status_version"
|
|
43
|
+
FULL_DISK_ACCESS_REASONS_KEY = "full_disk_access_reasons"
|
|
44
|
+
FULL_DISK_ACCESS_STATUS_VERSION = 1
|
|
45
|
+
FULL_DISK_ACCESS_UNSET = "unset"
|
|
46
|
+
FULL_DISK_ACCESS_GRANTED = "granted"
|
|
47
|
+
FULL_DISK_ACCESS_DECLINED = "declined"
|
|
48
|
+
FULL_DISK_ACCESS_LATER = "later"
|
|
49
|
+
VALID_FULL_DISK_ACCESS_STATUSES = {
|
|
50
|
+
FULL_DISK_ACCESS_UNSET,
|
|
51
|
+
FULL_DISK_ACCESS_GRANTED,
|
|
52
|
+
FULL_DISK_ACCESS_DECLINED,
|
|
53
|
+
FULL_DISK_ACCESS_LATER,
|
|
54
|
+
}
|
|
55
|
+
LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
56
|
+
LINUX_SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
|
57
|
+
MACOS_CAFFEINATE_PATH = Path("/usr/bin/caffeinate")
|
|
58
|
+
MACOS_CLOSED_LID_BEHAVIOR = "best_effort"
|
|
59
|
+
LINUX_CLOSED_LID_BEHAVIOR = "host_policy"
|
|
60
|
+
MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
|
|
61
|
+
MACOS_FDA_PROBE_PATHS = (
|
|
62
|
+
Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db",
|
|
63
|
+
Path.home() / "Library" / "Mail",
|
|
64
|
+
Path.home() / "Library" / "Messages",
|
|
65
|
+
Path.home() / "Library" / "Safari",
|
|
66
|
+
Path.home() / "Library" / "Application Support" / "AddressBook",
|
|
67
|
+
)
|
|
68
|
+
DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
|
|
69
|
+
DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
|
|
70
|
+
# Codex defaults mirror the user's primary model — no hardcoded third-party models.
|
|
71
|
+
DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
|
|
72
|
+
DEFAULT_CODEX_REASONING_EFFORT = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_launchagent_path() -> str:
|
|
76
|
+
"""Build a PATH string for LaunchAgent plists that includes nvm node if present."""
|
|
77
|
+
home = Path.home()
|
|
78
|
+
parts = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
|
|
79
|
+
str(home / ".local/bin"), str(home / ".nexo/bin")]
|
|
80
|
+
# Detect nvm node
|
|
81
|
+
nvm_dir = home / ".nvm/versions/node"
|
|
82
|
+
if nvm_dir.is_dir():
|
|
83
|
+
versions = sorted(nvm_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
84
|
+
for v in versions:
|
|
85
|
+
node_bin = v / "bin"
|
|
86
|
+
if (node_bin / "node").exists():
|
|
87
|
+
parts.insert(0, str(node_bin))
|
|
88
|
+
break
|
|
89
|
+
return ":".join(parts)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _schedule_defaults() -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"timezone": "UTC",
|
|
95
|
+
"auto_update": True,
|
|
96
|
+
"interactive_clients": {
|
|
97
|
+
"claude_code": True,
|
|
98
|
+
"codex": False,
|
|
99
|
+
"claude_desktop": False,
|
|
100
|
+
},
|
|
101
|
+
"default_terminal_client": "claude_code",
|
|
102
|
+
"automation_enabled": True,
|
|
103
|
+
"automation_backend": "claude_code",
|
|
104
|
+
"client_runtime_profiles": {
|
|
105
|
+
"claude_code": {
|
|
106
|
+
"model": DEFAULT_CLAUDE_CODE_MODEL,
|
|
107
|
+
"reasoning_effort": DEFAULT_CLAUDE_CODE_REASONING_EFFORT,
|
|
108
|
+
},
|
|
109
|
+
"codex": {
|
|
110
|
+
"model": DEFAULT_CODEX_MODEL,
|
|
111
|
+
"reasoning_effort": DEFAULT_CODEX_REASONING_EFFORT,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
"client_install_preferences": {
|
|
115
|
+
"claude_code": "ask",
|
|
116
|
+
"codex": "ask",
|
|
117
|
+
"claude_desktop": "manual",
|
|
118
|
+
},
|
|
119
|
+
POWER_POLICY_KEY: POWER_POLICY_UNSET,
|
|
120
|
+
POWER_POLICY_VERSION_KEY: POWER_POLICY_VERSION,
|
|
121
|
+
FULL_DISK_ACCESS_STATUS_KEY: FULL_DISK_ACCESS_UNSET,
|
|
122
|
+
FULL_DISK_ACCESS_STATUS_VERSION_KEY: FULL_DISK_ACCESS_STATUS_VERSION,
|
|
123
|
+
FULL_DISK_ACCESS_REASONS_KEY: [],
|
|
124
|
+
"processes": {},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_schedule_config() -> dict:
|
|
129
|
+
if not SCHEDULE_FILE.is_file():
|
|
130
|
+
return _schedule_defaults()
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(SCHEDULE_FILE.read_text())
|
|
133
|
+
except Exception:
|
|
134
|
+
return _schedule_defaults()
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
return _schedule_defaults()
|
|
137
|
+
merged = _schedule_defaults()
|
|
138
|
+
merged.update(data)
|
|
139
|
+
merged.setdefault("processes", {})
|
|
140
|
+
return merged
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_schedule_config(schedule: dict) -> Path:
|
|
144
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
payload = dict(_schedule_defaults())
|
|
146
|
+
payload.update(schedule or {})
|
|
147
|
+
payload.setdefault("processes", {})
|
|
148
|
+
payload[POWER_POLICY_KEY] = normalize_power_policy(payload.get(POWER_POLICY_KEY))
|
|
149
|
+
payload[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
|
|
150
|
+
payload[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(
|
|
151
|
+
payload.get(FULL_DISK_ACCESS_STATUS_KEY)
|
|
152
|
+
)
|
|
153
|
+
payload[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
|
|
154
|
+
payload[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(
|
|
155
|
+
payload.get(FULL_DISK_ACCESS_REASONS_KEY)
|
|
156
|
+
)
|
|
157
|
+
SCHEDULE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
158
|
+
return SCHEDULE_FILE
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalize_power_policy(value: str | None) -> str:
|
|
162
|
+
candidate = str(value or "").strip().lower()
|
|
163
|
+
if candidate in {"enabled", "yes", "on", "true", "1"}:
|
|
164
|
+
return POWER_POLICY_ALWAYS_ON
|
|
165
|
+
if candidate in {"disabled", "no", "off", "false", "0"}:
|
|
166
|
+
return POWER_POLICY_DISABLED
|
|
167
|
+
if candidate in VALID_POWER_POLICIES:
|
|
168
|
+
return candidate
|
|
169
|
+
return POWER_POLICY_UNSET
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _detect_linux_power_helper() -> tuple[str | None, str | None]:
|
|
173
|
+
if shutil.which("systemd-inhibit"):
|
|
174
|
+
return "systemd-inhibit", shutil.which("systemd-inhibit")
|
|
175
|
+
if shutil.which("caffeine"):
|
|
176
|
+
return "caffeine", shutil.which("caffeine")
|
|
177
|
+
return None, None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def describe_power_policy(policy: str | None = None, *, system: str | None = None) -> dict:
|
|
181
|
+
policy = normalize_power_policy(policy or get_power_policy())
|
|
182
|
+
system = system or platform.system()
|
|
183
|
+
base = {
|
|
184
|
+
"policy": policy,
|
|
185
|
+
"platform": system,
|
|
186
|
+
"helper": None,
|
|
187
|
+
"helper_path": None,
|
|
188
|
+
"helper_available": False,
|
|
189
|
+
"closed_lid_behavior": "n/a",
|
|
190
|
+
"requires_wake_recovery": True,
|
|
191
|
+
"summary": "",
|
|
192
|
+
"prompt_note": "",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if policy != POWER_POLICY_ALWAYS_ON:
|
|
196
|
+
state = "disabled" if policy == POWER_POLICY_DISABLED else "unset"
|
|
197
|
+
base["summary"] = f"Power helper {state}."
|
|
198
|
+
base["prompt_note"] = "Wake recovery and catchup remain available."
|
|
199
|
+
return base
|
|
200
|
+
|
|
201
|
+
if system == "Darwin":
|
|
202
|
+
available = MACOS_CAFFEINATE_PATH.is_file()
|
|
203
|
+
base.update({
|
|
204
|
+
"helper": "caffeinate",
|
|
205
|
+
"helper_path": str(MACOS_CAFFEINATE_PATH),
|
|
206
|
+
"helper_available": available,
|
|
207
|
+
"closed_lid_behavior": MACOS_CLOSED_LID_BEHAVIOR,
|
|
208
|
+
"summary": (
|
|
209
|
+
"Enable the native macOS caffeinate helper for best-effort "
|
|
210
|
+
"background availability."
|
|
211
|
+
),
|
|
212
|
+
"prompt_note": (
|
|
213
|
+
"macOS uses the native caffeinate helper. Closed-lid operation "
|
|
214
|
+
"depends on your hardware/setup, so wake recovery remains active."
|
|
215
|
+
),
|
|
216
|
+
})
|
|
217
|
+
return base
|
|
218
|
+
|
|
219
|
+
if system == "Linux":
|
|
220
|
+
helper, helper_path = _detect_linux_power_helper()
|
|
221
|
+
base.update({
|
|
222
|
+
"helper": helper,
|
|
223
|
+
"helper_path": helper_path,
|
|
224
|
+
"helper_available": bool(helper_path),
|
|
225
|
+
"closed_lid_behavior": LINUX_CLOSED_LID_BEHAVIOR,
|
|
226
|
+
"summary": (
|
|
227
|
+
"Enable the Linux power helper for best-effort background "
|
|
228
|
+
"availability."
|
|
229
|
+
),
|
|
230
|
+
"prompt_note": (
|
|
231
|
+
"Linux uses systemd-inhibit or caffeine when available. "
|
|
232
|
+
"Closed-lid behavior depends on host power settings, so wake "
|
|
233
|
+
"recovery remains active."
|
|
234
|
+
),
|
|
235
|
+
})
|
|
236
|
+
return base
|
|
237
|
+
|
|
238
|
+
base.update({
|
|
239
|
+
"summary": f"No power helper integration is available on {system}.",
|
|
240
|
+
"prompt_note": "Wake recovery and catchup remain available.",
|
|
241
|
+
})
|
|
242
|
+
return base
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _protected_macos_roots(home: Path | None = None) -> tuple[Path, ...]:
|
|
246
|
+
home = home or Path.home()
|
|
247
|
+
return (
|
|
248
|
+
home / "Documents",
|
|
249
|
+
home / "Desktop",
|
|
250
|
+
home / "Downloads",
|
|
251
|
+
home / "Library" / "Mobile Documents",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_protected_macos_path(candidate: str | os.PathLike[str] | Path | None) -> bool:
|
|
256
|
+
if not candidate:
|
|
257
|
+
return False
|
|
258
|
+
if platform.system() != "Darwin":
|
|
259
|
+
return False
|
|
260
|
+
resolved = Path(candidate).expanduser().resolve(strict=False)
|
|
261
|
+
return any(resolved == root or root in resolved.parents for root in _protected_macos_roots())
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def normalize_full_disk_access_status(value: str | None) -> str:
|
|
265
|
+
candidate = str(value or "").strip().lower()
|
|
266
|
+
if candidate in {"enabled", "yes", "approved", "ok", "true", "1"}:
|
|
267
|
+
return FULL_DISK_ACCESS_GRANTED
|
|
268
|
+
if candidate in {"no", "disabled", "off", "false", "0"}:
|
|
269
|
+
return FULL_DISK_ACCESS_DECLINED
|
|
270
|
+
if candidate in VALID_FULL_DISK_ACCESS_STATUSES:
|
|
271
|
+
return candidate
|
|
272
|
+
return FULL_DISK_ACCESS_UNSET
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def normalize_full_disk_access_reasons(value) -> list[str]:
|
|
276
|
+
if not value:
|
|
277
|
+
return []
|
|
278
|
+
if isinstance(value, str):
|
|
279
|
+
value = [value]
|
|
280
|
+
if not isinstance(value, (list, tuple, set)):
|
|
281
|
+
return []
|
|
282
|
+
reasons: list[str] = []
|
|
283
|
+
for item in value:
|
|
284
|
+
text = str(item or "").strip()
|
|
285
|
+
if text and text not in reasons:
|
|
286
|
+
reasons.append(text)
|
|
287
|
+
return reasons
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def get_full_disk_access_status(schedule: dict | None = None) -> str:
|
|
291
|
+
schedule = schedule or load_schedule_config()
|
|
292
|
+
return normalize_full_disk_access_status(schedule.get(FULL_DISK_ACCESS_STATUS_KEY))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def format_full_disk_access_label(status: str | None = None, *, system: str | None = None) -> str:
|
|
296
|
+
status = normalize_full_disk_access_status(status or get_full_disk_access_status())
|
|
297
|
+
system = system or platform.system()
|
|
298
|
+
if system != "Darwin":
|
|
299
|
+
return "not_applicable"
|
|
300
|
+
if status == FULL_DISK_ACCESS_GRANTED:
|
|
301
|
+
return "granted"
|
|
302
|
+
if status == FULL_DISK_ACCESS_DECLINED:
|
|
303
|
+
return "declined"
|
|
304
|
+
if status == FULL_DISK_ACCESS_LATER:
|
|
305
|
+
return "later"
|
|
306
|
+
return "unset"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _tail_has_permission_denial(log_file: Path) -> bool:
|
|
310
|
+
if not log_file.is_file():
|
|
311
|
+
return False
|
|
312
|
+
try:
|
|
313
|
+
with log_file.open("rb") as fh:
|
|
314
|
+
fh.seek(0, os.SEEK_END)
|
|
315
|
+
size = fh.tell()
|
|
316
|
+
fh.seek(max(size - 4096, 0))
|
|
317
|
+
tail = fh.read().decode("utf-8", errors="ignore")
|
|
318
|
+
return "Operation not permitted" in tail
|
|
319
|
+
except Exception:
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def detect_full_disk_access_reasons(*, system: str | None = None) -> list[str]:
|
|
324
|
+
system = system or platform.system()
|
|
325
|
+
if system != "Darwin":
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
reasons: list[str] = []
|
|
329
|
+
if _is_protected_macos_path(NEXO_HOME):
|
|
330
|
+
reasons.append(
|
|
331
|
+
f"NEXO_HOME is inside a protected macOS folder: {NEXO_HOME}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
logs_dir = NEXO_HOME / "logs"
|
|
335
|
+
if logs_dir.is_dir():
|
|
336
|
+
for log_file in sorted(logs_dir.glob("*-stderr.log")):
|
|
337
|
+
if _tail_has_permission_denial(log_file):
|
|
338
|
+
reasons.append(
|
|
339
|
+
f"Recent background job stderr hit 'Operation not permitted' ({log_file.name})"
|
|
340
|
+
)
|
|
341
|
+
break
|
|
342
|
+
return reasons
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _runtime_python_candidates() -> list[str]:
|
|
346
|
+
candidates: list[str] = []
|
|
347
|
+
runtime_python = NEXO_HOME / ".venv" / "bin" / "python3"
|
|
348
|
+
if runtime_python.is_file():
|
|
349
|
+
candidates.append(str(runtime_python))
|
|
350
|
+
if sys.executable:
|
|
351
|
+
candidates.append(sys.executable)
|
|
352
|
+
python3_path = shutil.which("python3")
|
|
353
|
+
if python3_path:
|
|
354
|
+
candidates.append(python3_path)
|
|
355
|
+
seen: set[str] = set()
|
|
356
|
+
ordered: list[str] = []
|
|
357
|
+
for item in candidates:
|
|
358
|
+
if item and item not in seen:
|
|
359
|
+
seen.add(item)
|
|
360
|
+
ordered.append(item)
|
|
361
|
+
return ordered
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def full_disk_access_targets() -> list[str]:
|
|
365
|
+
targets = ["/bin/bash", *(_runtime_python_candidates())]
|
|
366
|
+
seen: set[str] = set()
|
|
367
|
+
ordered: list[str] = []
|
|
368
|
+
for item in targets:
|
|
369
|
+
if item and item not in seen:
|
|
370
|
+
seen.add(item)
|
|
371
|
+
ordered.append(item)
|
|
372
|
+
return ordered
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def open_full_disk_access_settings() -> dict:
|
|
376
|
+
if platform.system() != "Darwin":
|
|
377
|
+
return {"ok": False, "opened": False, "message": "Full Disk Access setup is macOS-only."}
|
|
378
|
+
try:
|
|
379
|
+
result = subprocess.run(
|
|
380
|
+
["open", MACOS_FDA_SETTINGS_URL],
|
|
381
|
+
capture_output=True,
|
|
382
|
+
text=True,
|
|
383
|
+
timeout=5,
|
|
384
|
+
)
|
|
385
|
+
ok = result.returncode == 0
|
|
386
|
+
return {
|
|
387
|
+
"ok": ok,
|
|
388
|
+
"opened": ok,
|
|
389
|
+
"message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
|
|
390
|
+
}
|
|
391
|
+
except Exception as e:
|
|
392
|
+
return {"ok": False, "opened": False, "message": str(e)}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _probe_candidates() -> list[Path]:
|
|
396
|
+
candidates: list[Path] = []
|
|
397
|
+
for path_candidate in MACOS_FDA_PROBE_PATHS:
|
|
398
|
+
expanded = path_candidate.expanduser()
|
|
399
|
+
if expanded.exists():
|
|
400
|
+
candidates.append(expanded)
|
|
401
|
+
if _is_protected_macos_path(NEXO_HOME):
|
|
402
|
+
candidates.append(NEXO_HOME)
|
|
403
|
+
return candidates
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def probe_full_disk_access() -> dict:
|
|
407
|
+
if platform.system() != "Darwin":
|
|
408
|
+
return {"checked": False, "granted": None, "probe_path": None, "message": "macOS-only"}
|
|
409
|
+
|
|
410
|
+
candidates = _probe_candidates()
|
|
411
|
+
if not candidates:
|
|
412
|
+
return {
|
|
413
|
+
"checked": False,
|
|
414
|
+
"granted": None,
|
|
415
|
+
"probe_path": None,
|
|
416
|
+
"message": "No local probe path available for verification.",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
script = 'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi'
|
|
420
|
+
last_error = ""
|
|
421
|
+
for candidate in candidates:
|
|
422
|
+
try:
|
|
423
|
+
result = subprocess.run(
|
|
424
|
+
["/bin/bash", "-lc", script, "_", str(candidate)],
|
|
425
|
+
capture_output=True,
|
|
426
|
+
text=True,
|
|
427
|
+
timeout=5,
|
|
428
|
+
)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
last_error = str(e)
|
|
431
|
+
continue
|
|
432
|
+
if result.returncode == 0:
|
|
433
|
+
return {
|
|
434
|
+
"checked": True,
|
|
435
|
+
"granted": True,
|
|
436
|
+
"probe_path": str(candidate),
|
|
437
|
+
"message": "",
|
|
438
|
+
}
|
|
439
|
+
last_error = result.stderr.strip() or result.stdout.strip()
|
|
440
|
+
return {
|
|
441
|
+
"checked": True,
|
|
442
|
+
"granted": False,
|
|
443
|
+
"probe_path": str(candidates[0]),
|
|
444
|
+
"message": last_error,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def prompt_for_full_disk_access(
|
|
449
|
+
*,
|
|
450
|
+
reason: str = "install",
|
|
451
|
+
reasons: list[str] | None = None,
|
|
452
|
+
input_fn=input,
|
|
453
|
+
output_fn=print,
|
|
454
|
+
open_fn=open_full_disk_access_settings,
|
|
455
|
+
probe_fn=probe_full_disk_access,
|
|
456
|
+
) -> dict:
|
|
457
|
+
reasons = normalize_full_disk_access_reasons(reasons)
|
|
458
|
+
output_fn(
|
|
459
|
+
"[NEXO] Some macOS background automations may need Full Disk Access. "
|
|
460
|
+
"macOS does not allow granting it automatically."
|
|
461
|
+
)
|
|
462
|
+
if reasons:
|
|
463
|
+
output_fn("[NEXO] Reason(s) detected:")
|
|
464
|
+
for item in reasons:
|
|
465
|
+
output_fn(f"[NEXO] - {item}")
|
|
466
|
+
output_fn("[NEXO] If you continue, NEXO will open the correct System Settings screen.")
|
|
467
|
+
output_fn("[NEXO] Add your terminal app and, if needed for background jobs, these binaries:")
|
|
468
|
+
for target in full_disk_access_targets():
|
|
469
|
+
output_fn(f"[NEXO] - {target}")
|
|
470
|
+
|
|
471
|
+
prompt = "[NEXO] Open Full Disk Access setup now? [y]es / [n]o / [l]ater: "
|
|
472
|
+
while True:
|
|
473
|
+
answer = str(input_fn(prompt)).strip().lower()
|
|
474
|
+
if answer in {"y", "yes"}:
|
|
475
|
+
open_result = open_fn()
|
|
476
|
+
if open_result.get("opened"):
|
|
477
|
+
output_fn("[NEXO] System Settings opened at Privacy & Security → Full Disk Access.")
|
|
478
|
+
elif open_result.get("message"):
|
|
479
|
+
output_fn(f"[NEXO] Could not open System Settings automatically: {open_result['message']}")
|
|
480
|
+
output_fn("[NEXO] Grant the permission, then press Enter to verify.")
|
|
481
|
+
follow_up = str(
|
|
482
|
+
input_fn("[NEXO] Press Enter after granting it, or type later to skip for now: ")
|
|
483
|
+
).strip().lower()
|
|
484
|
+
if follow_up in {"later", "l"}:
|
|
485
|
+
return {
|
|
486
|
+
"status": FULL_DISK_ACCESS_LATER,
|
|
487
|
+
"settings_opened": bool(open_result.get("opened")),
|
|
488
|
+
"verified": False,
|
|
489
|
+
"message": "Full Disk Access setup deferred for later.",
|
|
490
|
+
}
|
|
491
|
+
probe = probe_fn()
|
|
492
|
+
if probe.get("granted") is True:
|
|
493
|
+
return {
|
|
494
|
+
"status": FULL_DISK_ACCESS_GRANTED,
|
|
495
|
+
"settings_opened": bool(open_result.get("opened")),
|
|
496
|
+
"verified": True,
|
|
497
|
+
"message": f"Full Disk Access verified via {probe.get('probe_path')}.",
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
"status": FULL_DISK_ACCESS_LATER,
|
|
501
|
+
"settings_opened": bool(open_result.get("opened")),
|
|
502
|
+
"verified": False,
|
|
503
|
+
"message": (
|
|
504
|
+
"Could not verify Full Disk Access yet. NEXO will remind you later if "
|
|
505
|
+
"background jobs still hit TCC."
|
|
506
|
+
),
|
|
507
|
+
}
|
|
508
|
+
if answer in {"n", "no"}:
|
|
509
|
+
return {
|
|
510
|
+
"status": FULL_DISK_ACCESS_DECLINED,
|
|
511
|
+
"settings_opened": False,
|
|
512
|
+
"verified": False,
|
|
513
|
+
"message": "Full Disk Access was declined.",
|
|
514
|
+
}
|
|
515
|
+
if answer in {"l", "later", ""}:
|
|
516
|
+
return {
|
|
517
|
+
"status": FULL_DISK_ACCESS_LATER,
|
|
518
|
+
"settings_opened": False,
|
|
519
|
+
"verified": False,
|
|
520
|
+
"message": "Full Disk Access setup deferred for later.",
|
|
521
|
+
}
|
|
522
|
+
output_fn("[NEXO] Reply with yes, no, or later.")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def format_power_policy_label(policy: str | None = None, *, system: str | None = None) -> str:
|
|
526
|
+
details = describe_power_policy(policy=policy, system=system)
|
|
527
|
+
policy = details["policy"]
|
|
528
|
+
if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Darwin":
|
|
529
|
+
return "always_on (macOS caffeinate, closed-lid best effort)"
|
|
530
|
+
if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Linux":
|
|
531
|
+
helper = details["helper"] or "power helper"
|
|
532
|
+
return f"always_on ({helper}, closed-lid depends on host policy)"
|
|
533
|
+
return policy
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def get_power_policy(schedule: dict | None = None) -> str:
|
|
537
|
+
schedule = schedule or load_schedule_config()
|
|
538
|
+
return normalize_power_policy(schedule.get(POWER_POLICY_KEY))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def is_power_policy_configured(schedule: dict | None = None) -> bool:
|
|
542
|
+
return get_power_policy(schedule) != POWER_POLICY_UNSET
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def set_power_policy(policy: str) -> dict:
|
|
546
|
+
schedule = load_schedule_config()
|
|
547
|
+
schedule[POWER_POLICY_KEY] = normalize_power_policy(policy)
|
|
548
|
+
schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
|
|
549
|
+
save_schedule_config(schedule)
|
|
550
|
+
return schedule
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def set_full_disk_access_status(status: str, *, reasons: list[str] | None = None) -> dict:
|
|
554
|
+
schedule = load_schedule_config()
|
|
555
|
+
schedule[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(status)
|
|
556
|
+
schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
|
|
557
|
+
if reasons is not None:
|
|
558
|
+
schedule[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(reasons)
|
|
559
|
+
save_schedule_config(schedule)
|
|
560
|
+
return schedule
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def prompt_for_power_policy(
|
|
564
|
+
*,
|
|
565
|
+
reason: str = "install",
|
|
566
|
+
system: str | None = None,
|
|
567
|
+
input_fn=input,
|
|
568
|
+
output_fn=print,
|
|
569
|
+
) -> str:
|
|
570
|
+
details = describe_power_policy(POWER_POLICY_ALWAYS_ON, system=system)
|
|
571
|
+
prompt = (
|
|
572
|
+
"[NEXO] Enable the background power helper for this machine? "
|
|
573
|
+
"[y]es / [n]o / [l]ater: "
|
|
574
|
+
)
|
|
575
|
+
output_fn(
|
|
576
|
+
"[NEXO] This controls the optional prevent-sleep helper. "
|
|
577
|
+
"It improves background availability but remains opt-in."
|
|
578
|
+
)
|
|
579
|
+
output_fn(f"[NEXO] {details['prompt_note']}")
|
|
580
|
+
while True:
|
|
581
|
+
answer = str(input_fn(prompt)).strip().lower()
|
|
582
|
+
if answer in {"y", "yes"}:
|
|
583
|
+
return POWER_POLICY_ALWAYS_ON
|
|
584
|
+
if answer in {"n", "no"}:
|
|
585
|
+
return POWER_POLICY_DISABLED
|
|
586
|
+
if answer in {"l", "later", ""}:
|
|
587
|
+
return POWER_POLICY_UNSET
|
|
588
|
+
output_fn("[NEXO] Reply with yes, no, or later.")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def ensure_power_policy_choice(
|
|
592
|
+
*,
|
|
593
|
+
interactive: bool,
|
|
594
|
+
reason: str = "update",
|
|
595
|
+
input_fn=input,
|
|
596
|
+
output_fn=print,
|
|
597
|
+
) -> dict:
|
|
598
|
+
schedule = load_schedule_config()
|
|
599
|
+
policy = get_power_policy(schedule)
|
|
600
|
+
prompted = False
|
|
601
|
+
if interactive and policy == POWER_POLICY_UNSET:
|
|
602
|
+
prompted = True
|
|
603
|
+
policy = prompt_for_power_policy(
|
|
604
|
+
reason=reason,
|
|
605
|
+
system=platform.system(),
|
|
606
|
+
input_fn=input_fn,
|
|
607
|
+
output_fn=output_fn,
|
|
608
|
+
)
|
|
609
|
+
schedule[POWER_POLICY_KEY] = policy
|
|
610
|
+
schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
|
|
611
|
+
save_schedule_config(schedule)
|
|
612
|
+
return {
|
|
613
|
+
"policy": policy,
|
|
614
|
+
"prompted": prompted,
|
|
615
|
+
"schedule_file": str(SCHEDULE_FILE),
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def ensure_full_disk_access_choice(
|
|
620
|
+
*,
|
|
621
|
+
interactive: bool,
|
|
622
|
+
reason: str = "update",
|
|
623
|
+
input_fn=input,
|
|
624
|
+
output_fn=print,
|
|
625
|
+
open_fn=open_full_disk_access_settings,
|
|
626
|
+
probe_fn=probe_full_disk_access,
|
|
627
|
+
) -> dict:
|
|
628
|
+
schedule = load_schedule_config()
|
|
629
|
+
system = platform.system()
|
|
630
|
+
status = get_full_disk_access_status(schedule)
|
|
631
|
+
reasons = detect_full_disk_access_reasons(system=system)
|
|
632
|
+
prompted = False
|
|
633
|
+
verified = False
|
|
634
|
+
settings_opened = False
|
|
635
|
+
message = ""
|
|
636
|
+
|
|
637
|
+
if system != "Darwin":
|
|
638
|
+
return {
|
|
639
|
+
"status": status,
|
|
640
|
+
"prompted": False,
|
|
641
|
+
"verified": False,
|
|
642
|
+
"settings_opened": False,
|
|
643
|
+
"reasons": [],
|
|
644
|
+
"schedule_file": str(SCHEDULE_FILE),
|
|
645
|
+
"message": "",
|
|
646
|
+
"relevant": False,
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
schedule[FULL_DISK_ACCESS_REASONS_KEY] = reasons
|
|
650
|
+
schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
|
|
651
|
+
|
|
652
|
+
if not reasons:
|
|
653
|
+
save_schedule_config(schedule)
|
|
654
|
+
return {
|
|
655
|
+
"status": status,
|
|
656
|
+
"prompted": False,
|
|
657
|
+
"verified": False,
|
|
658
|
+
"settings_opened": False,
|
|
659
|
+
"reasons": [],
|
|
660
|
+
"schedule_file": str(SCHEDULE_FILE),
|
|
661
|
+
"message": "",
|
|
662
|
+
"relevant": False,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if status == FULL_DISK_ACCESS_GRANTED:
|
|
666
|
+
probe = probe_fn()
|
|
667
|
+
if probe.get("granted") is True:
|
|
668
|
+
verified = True
|
|
669
|
+
message = f"Full Disk Access verified via {probe.get('probe_path')}."
|
|
670
|
+
else:
|
|
671
|
+
status = FULL_DISK_ACCESS_LATER
|
|
672
|
+
message = (
|
|
673
|
+
"Full Disk Access was configured previously but could not be verified. "
|
|
674
|
+
"NEXO will remind you again on the next interactive update."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
elif interactive and status in {FULL_DISK_ACCESS_UNSET, FULL_DISK_ACCESS_LATER}:
|
|
678
|
+
prompted = True
|
|
679
|
+
prompt_result = prompt_for_full_disk_access(
|
|
680
|
+
reason=reason,
|
|
681
|
+
reasons=reasons,
|
|
682
|
+
input_fn=input_fn,
|
|
683
|
+
output_fn=output_fn,
|
|
684
|
+
open_fn=open_fn,
|
|
685
|
+
probe_fn=probe_fn,
|
|
686
|
+
)
|
|
687
|
+
status = normalize_full_disk_access_status(prompt_result.get("status"))
|
|
688
|
+
verified = bool(prompt_result.get("verified"))
|
|
689
|
+
settings_opened = bool(prompt_result.get("settings_opened"))
|
|
690
|
+
message = str(prompt_result.get("message") or "")
|
|
691
|
+
|
|
692
|
+
elif status == FULL_DISK_ACCESS_DECLINED:
|
|
693
|
+
message = (
|
|
694
|
+
"Full Disk Access remains declined. Background jobs that touch protected "
|
|
695
|
+
"macOS folders may fail."
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
schedule[FULL_DISK_ACCESS_STATUS_KEY] = status
|
|
699
|
+
save_schedule_config(schedule)
|
|
700
|
+
return {
|
|
701
|
+
"status": status,
|
|
702
|
+
"prompted": prompted,
|
|
703
|
+
"verified": verified,
|
|
704
|
+
"settings_opened": settings_opened,
|
|
705
|
+
"reasons": reasons,
|
|
706
|
+
"schedule_file": str(SCHEDULE_FILE),
|
|
707
|
+
"message": message,
|
|
708
|
+
"relevant": True,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _prevent_sleep_script_path() -> Path:
|
|
713
|
+
runtime_script = NEXO_HOME / "scripts" / "nexo-prevent-sleep.sh"
|
|
714
|
+
if runtime_script.is_file():
|
|
715
|
+
return runtime_script
|
|
716
|
+
source_script = NEXO_CODE / "scripts" / "nexo-prevent-sleep.sh"
|
|
717
|
+
return source_script
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _macos_prevent_sleep_plist() -> tuple[Path, dict]:
|
|
721
|
+
script_path = _prevent_sleep_script_path()
|
|
722
|
+
plist_path = LAUNCH_AGENTS_DIR / "com.nexo.prevent-sleep.plist"
|
|
723
|
+
plist = {
|
|
724
|
+
"Label": "com.nexo.prevent-sleep",
|
|
725
|
+
"ProgramArguments": ["/bin/bash", str(script_path)],
|
|
726
|
+
"RunAtLoad": True,
|
|
727
|
+
"KeepAlive": True,
|
|
728
|
+
"StandardOutPath": str(NEXO_HOME / "logs" / "prevent-sleep-stdout.log"),
|
|
729
|
+
"StandardErrorPath": str(NEXO_HOME / "logs" / "prevent-sleep-stderr.log"),
|
|
730
|
+
"EnvironmentVariables": {
|
|
731
|
+
"HOME": str(Path.home()),
|
|
732
|
+
"NEXO_HOME": str(NEXO_HOME),
|
|
733
|
+
"NEXO_CODE": str(NEXO_HOME),
|
|
734
|
+
"PATH": resolve_launchagent_path(),
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
return plist_path, plist
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _linux_prevent_sleep_service() -> tuple[Path, str]:
|
|
741
|
+
script_path = _prevent_sleep_script_path()
|
|
742
|
+
service_path = LINUX_SYSTEMD_USER_DIR / "nexo-prevent-sleep.service"
|
|
743
|
+
body = f"""[Unit]
|
|
744
|
+
Description=NEXO prevent sleep
|
|
745
|
+
|
|
746
|
+
[Service]
|
|
747
|
+
Type=simple
|
|
748
|
+
ExecStart=/bin/bash {script_path}
|
|
749
|
+
Environment=HOME={Path.home()}
|
|
750
|
+
Environment=NEXO_HOME={NEXO_HOME}
|
|
751
|
+
Environment=NEXO_CODE={NEXO_HOME}
|
|
752
|
+
Restart=always
|
|
753
|
+
RestartSec=5
|
|
754
|
+
|
|
755
|
+
[Install]
|
|
756
|
+
WantedBy=default.target
|
|
757
|
+
"""
|
|
758
|
+
return service_path, body
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def apply_power_policy(policy: str | None = None) -> dict:
|
|
762
|
+
policy = normalize_power_policy(policy or get_power_policy())
|
|
763
|
+
system = platform.system()
|
|
764
|
+
logs_dir = NEXO_HOME / "logs"
|
|
765
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
766
|
+
details = describe_power_policy(policy=policy, system=system)
|
|
767
|
+
|
|
768
|
+
if system == "Darwin":
|
|
769
|
+
return _apply_macos_power_policy(policy, details=details)
|
|
770
|
+
if system == "Linux":
|
|
771
|
+
return _apply_linux_power_policy(policy, details=details)
|
|
772
|
+
return {
|
|
773
|
+
"ok": policy != POWER_POLICY_ALWAYS_ON,
|
|
774
|
+
"policy": policy,
|
|
775
|
+
"platform": system,
|
|
776
|
+
"action": "unsupported",
|
|
777
|
+
"message": f"Unsupported platform for prevent-sleep policy: {system}",
|
|
778
|
+
"details": details,
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _apply_macos_power_policy(policy: str, *, details: dict | None = None) -> dict:
|
|
783
|
+
plist_path, plist = _macos_prevent_sleep_plist()
|
|
784
|
+
label = plist["Label"]
|
|
785
|
+
uid = str(os.getuid())
|
|
786
|
+
if policy == POWER_POLICY_ALWAYS_ON:
|
|
787
|
+
details = details or describe_power_policy(policy, system="Darwin")
|
|
788
|
+
if not details.get("helper_available"):
|
|
789
|
+
return {
|
|
790
|
+
"ok": False,
|
|
791
|
+
"policy": policy,
|
|
792
|
+
"platform": "Darwin",
|
|
793
|
+
"action": "missing-helper",
|
|
794
|
+
"message": f"Required helper not found: {details.get('helper_path') or 'caffeinate'}",
|
|
795
|
+
"details": details,
|
|
796
|
+
}
|
|
797
|
+
LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
798
|
+
with plist_path.open("wb") as fh:
|
|
799
|
+
plistlib.dump(plist, fh)
|
|
800
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
|
|
801
|
+
result = subprocess.run(
|
|
802
|
+
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
803
|
+
capture_output=True,
|
|
804
|
+
text=True,
|
|
805
|
+
)
|
|
806
|
+
ok = result.returncode == 0
|
|
807
|
+
return {
|
|
808
|
+
"ok": ok,
|
|
809
|
+
"policy": policy,
|
|
810
|
+
"platform": "Darwin",
|
|
811
|
+
"action": "enabled",
|
|
812
|
+
"plist_path": str(plist_path),
|
|
813
|
+
"message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
|
|
814
|
+
"details": details,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
|
|
818
|
+
if plist_path.exists():
|
|
819
|
+
plist_path.unlink()
|
|
820
|
+
subprocess.run(["launchctl", "remove", label], capture_output=True)
|
|
821
|
+
return {
|
|
822
|
+
"ok": True,
|
|
823
|
+
"policy": policy,
|
|
824
|
+
"platform": "Darwin",
|
|
825
|
+
"action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
|
|
826
|
+
"plist_path": str(plist_path),
|
|
827
|
+
"details": details or describe_power_policy(policy, system="Darwin"),
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _apply_linux_power_policy(policy: str, *, details: dict | None = None) -> dict:
|
|
832
|
+
service_path, service_body = _linux_prevent_sleep_service()
|
|
833
|
+
if policy == POWER_POLICY_ALWAYS_ON:
|
|
834
|
+
details = details or describe_power_policy(policy, system="Linux")
|
|
835
|
+
if not details.get("helper_available"):
|
|
836
|
+
return {
|
|
837
|
+
"ok": False,
|
|
838
|
+
"policy": policy,
|
|
839
|
+
"platform": "Linux",
|
|
840
|
+
"action": "missing-helper",
|
|
841
|
+
"message": "No Linux power helper found. Install systemd-inhibit or caffeine.",
|
|
842
|
+
"details": details,
|
|
843
|
+
}
|
|
844
|
+
LINUX_SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
|
|
845
|
+
service_path.write_text(service_body)
|
|
846
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
847
|
+
result = subprocess.run(
|
|
848
|
+
["systemctl", "--user", "enable", "--now", "nexo-prevent-sleep.service"],
|
|
849
|
+
capture_output=True,
|
|
850
|
+
text=True,
|
|
851
|
+
)
|
|
852
|
+
ok = result.returncode == 0
|
|
853
|
+
return {
|
|
854
|
+
"ok": ok,
|
|
855
|
+
"policy": policy,
|
|
856
|
+
"platform": "Linux",
|
|
857
|
+
"action": "enabled",
|
|
858
|
+
"service_path": str(service_path),
|
|
859
|
+
"message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
|
|
860
|
+
"details": details,
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
subprocess.run(["systemctl", "--user", "disable", "--now", "nexo-prevent-sleep.service"], capture_output=True)
|
|
864
|
+
if service_path.exists():
|
|
865
|
+
service_path.unlink()
|
|
866
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
867
|
+
return {
|
|
868
|
+
"ok": True,
|
|
869
|
+
"policy": policy,
|
|
870
|
+
"platform": "Linux",
|
|
871
|
+
"action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
|
|
872
|
+
"service_path": str(service_path),
|
|
873
|
+
"details": details or describe_power_policy(policy, system="Linux"),
|
|
874
|
+
}
|