nexo-brain 2.6.4 → 2.6.6

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.
@@ -4,13 +4,22 @@ from __future__ import annotations
4
4
  Manages the optional "prevent sleep" helper as an explicit, persisted runtime
5
5
  preference. The policy is stored in config/schedule.json to avoid introducing a
6
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.
7
14
  """
8
15
 
9
16
  import json
10
17
  import os
11
18
  import platform
12
19
  import plistlib
20
+ import shutil
13
21
  import subprocess
22
+ import sys
14
23
  from pathlib import Path
15
24
 
16
25
 
@@ -20,7 +29,7 @@ CONFIG_DIR = NEXO_HOME / "config"
20
29
  SCHEDULE_FILE = CONFIG_DIR / "schedule.json"
21
30
  POWER_POLICY_KEY = "power_policy"
22
31
  POWER_POLICY_VERSION_KEY = "power_policy_version"
23
- POWER_POLICY_VERSION = 1
32
+ POWER_POLICY_VERSION = 2
24
33
  POWER_POLICY_ALWAYS_ON = "always_on"
25
34
  POWER_POLICY_DISABLED = "disabled"
26
35
  POWER_POLICY_UNSET = "unset"
@@ -29,8 +38,33 @@ VALID_POWER_POLICIES = {
29
38
  POWER_POLICY_DISABLED,
30
39
  POWER_POLICY_UNSET,
31
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
+ }
32
55
  LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
33
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
+ )
34
68
 
35
69
 
36
70
  def _schedule_defaults() -> dict:
@@ -39,6 +73,9 @@ def _schedule_defaults() -> dict:
39
73
  "auto_update": True,
40
74
  POWER_POLICY_KEY: POWER_POLICY_UNSET,
41
75
  POWER_POLICY_VERSION_KEY: POWER_POLICY_VERSION,
76
+ FULL_DISK_ACCESS_STATUS_KEY: FULL_DISK_ACCESS_UNSET,
77
+ FULL_DISK_ACCESS_STATUS_VERSION_KEY: FULL_DISK_ACCESS_STATUS_VERSION,
78
+ FULL_DISK_ACCESS_REASONS_KEY: [],
42
79
  "processes": {},
43
80
  }
44
81
 
@@ -65,6 +102,13 @@ def save_schedule_config(schedule: dict) -> Path:
65
102
  payload.setdefault("processes", {})
66
103
  payload[POWER_POLICY_KEY] = normalize_power_policy(payload.get(POWER_POLICY_KEY))
67
104
  payload[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
105
+ payload[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(
106
+ payload.get(FULL_DISK_ACCESS_STATUS_KEY)
107
+ )
108
+ payload[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
109
+ payload[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(
110
+ payload.get(FULL_DISK_ACCESS_REASONS_KEY)
111
+ )
68
112
  SCHEDULE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
69
113
  return SCHEDULE_FILE
70
114
 
@@ -80,6 +124,370 @@ def normalize_power_policy(value: str | None) -> str:
80
124
  return POWER_POLICY_UNSET
81
125
 
82
126
 
127
+ def _detect_linux_power_helper() -> tuple[str | None, str | None]:
128
+ if shutil.which("systemd-inhibit"):
129
+ return "systemd-inhibit", shutil.which("systemd-inhibit")
130
+ if shutil.which("caffeine"):
131
+ return "caffeine", shutil.which("caffeine")
132
+ return None, None
133
+
134
+
135
+ def describe_power_policy(policy: str | None = None, *, system: str | None = None) -> dict:
136
+ policy = normalize_power_policy(policy or get_power_policy())
137
+ system = system or platform.system()
138
+ base = {
139
+ "policy": policy,
140
+ "platform": system,
141
+ "helper": None,
142
+ "helper_path": None,
143
+ "helper_available": False,
144
+ "closed_lid_behavior": "n/a",
145
+ "requires_wake_recovery": True,
146
+ "summary": "",
147
+ "prompt_note": "",
148
+ }
149
+
150
+ if policy != POWER_POLICY_ALWAYS_ON:
151
+ state = "disabled" if policy == POWER_POLICY_DISABLED else "unset"
152
+ base["summary"] = f"Power helper {state}."
153
+ base["prompt_note"] = "Wake recovery and catchup remain available."
154
+ return base
155
+
156
+ if system == "Darwin":
157
+ available = MACOS_CAFFEINATE_PATH.is_file()
158
+ base.update({
159
+ "helper": "caffeinate",
160
+ "helper_path": str(MACOS_CAFFEINATE_PATH),
161
+ "helper_available": available,
162
+ "closed_lid_behavior": MACOS_CLOSED_LID_BEHAVIOR,
163
+ "summary": (
164
+ "Enable the native macOS caffeinate helper for best-effort "
165
+ "background availability."
166
+ ),
167
+ "prompt_note": (
168
+ "macOS uses the native caffeinate helper. Closed-lid operation "
169
+ "depends on your hardware/setup, so wake recovery remains active."
170
+ ),
171
+ })
172
+ return base
173
+
174
+ if system == "Linux":
175
+ helper, helper_path = _detect_linux_power_helper()
176
+ base.update({
177
+ "helper": helper,
178
+ "helper_path": helper_path,
179
+ "helper_available": bool(helper_path),
180
+ "closed_lid_behavior": LINUX_CLOSED_LID_BEHAVIOR,
181
+ "summary": (
182
+ "Enable the Linux power helper for best-effort background "
183
+ "availability."
184
+ ),
185
+ "prompt_note": (
186
+ "Linux uses systemd-inhibit or caffeine when available. "
187
+ "Closed-lid behavior depends on host power settings, so wake "
188
+ "recovery remains active."
189
+ ),
190
+ })
191
+ return base
192
+
193
+ base.update({
194
+ "summary": f"No power helper integration is available on {system}.",
195
+ "prompt_note": "Wake recovery and catchup remain available.",
196
+ })
197
+ return base
198
+
199
+
200
+ def _protected_macos_roots(home: Path | None = None) -> tuple[Path, ...]:
201
+ home = home or Path.home()
202
+ return (
203
+ home / "Documents",
204
+ home / "Desktop",
205
+ home / "Downloads",
206
+ home / "Library" / "Mobile Documents",
207
+ )
208
+
209
+
210
+ def _is_protected_macos_path(candidate: str | os.PathLike[str] | Path | None) -> bool:
211
+ if not candidate:
212
+ return False
213
+ if platform.system() != "Darwin":
214
+ return False
215
+ resolved = Path(candidate).expanduser().resolve(strict=False)
216
+ return any(resolved == root or root in resolved.parents for root in _protected_macos_roots())
217
+
218
+
219
+ def normalize_full_disk_access_status(value: str | None) -> str:
220
+ candidate = str(value or "").strip().lower()
221
+ if candidate in {"enabled", "yes", "approved", "ok", "true", "1"}:
222
+ return FULL_DISK_ACCESS_GRANTED
223
+ if candidate in {"no", "disabled", "off", "false", "0"}:
224
+ return FULL_DISK_ACCESS_DECLINED
225
+ if candidate in VALID_FULL_DISK_ACCESS_STATUSES:
226
+ return candidate
227
+ return FULL_DISK_ACCESS_UNSET
228
+
229
+
230
+ def normalize_full_disk_access_reasons(value) -> list[str]:
231
+ if not value:
232
+ return []
233
+ if isinstance(value, str):
234
+ value = [value]
235
+ if not isinstance(value, (list, tuple, set)):
236
+ return []
237
+ reasons: list[str] = []
238
+ for item in value:
239
+ text = str(item or "").strip()
240
+ if text and text not in reasons:
241
+ reasons.append(text)
242
+ return reasons
243
+
244
+
245
+ def get_full_disk_access_status(schedule: dict | None = None) -> str:
246
+ schedule = schedule or load_schedule_config()
247
+ return normalize_full_disk_access_status(schedule.get(FULL_DISK_ACCESS_STATUS_KEY))
248
+
249
+
250
+ def format_full_disk_access_label(status: str | None = None, *, system: str | None = None) -> str:
251
+ status = normalize_full_disk_access_status(status or get_full_disk_access_status())
252
+ system = system or platform.system()
253
+ if system != "Darwin":
254
+ return "not_applicable"
255
+ if status == FULL_DISK_ACCESS_GRANTED:
256
+ return "granted"
257
+ if status == FULL_DISK_ACCESS_DECLINED:
258
+ return "declined"
259
+ if status == FULL_DISK_ACCESS_LATER:
260
+ return "later"
261
+ return "unset"
262
+
263
+
264
+ def _tail_has_permission_denial(log_file: Path) -> bool:
265
+ if not log_file.is_file():
266
+ return False
267
+ try:
268
+ with log_file.open("rb") as fh:
269
+ fh.seek(0, os.SEEK_END)
270
+ size = fh.tell()
271
+ fh.seek(max(size - 4096, 0))
272
+ tail = fh.read().decode("utf-8", errors="ignore")
273
+ return "Operation not permitted" in tail
274
+ except Exception:
275
+ return False
276
+
277
+
278
+ def detect_full_disk_access_reasons(*, system: str | None = None) -> list[str]:
279
+ system = system or platform.system()
280
+ if system != "Darwin":
281
+ return []
282
+
283
+ reasons: list[str] = []
284
+ if _is_protected_macos_path(NEXO_HOME):
285
+ reasons.append(
286
+ f"NEXO_HOME is inside a protected macOS folder: {NEXO_HOME}"
287
+ )
288
+
289
+ logs_dir = NEXO_HOME / "logs"
290
+ if logs_dir.is_dir():
291
+ for log_file in sorted(logs_dir.glob("*-stderr.log")):
292
+ if _tail_has_permission_denial(log_file):
293
+ reasons.append(
294
+ f"Recent background job stderr hit 'Operation not permitted' ({log_file.name})"
295
+ )
296
+ break
297
+ return reasons
298
+
299
+
300
+ def _runtime_python_candidates() -> list[str]:
301
+ candidates: list[str] = []
302
+ runtime_python = NEXO_HOME / ".venv" / "bin" / "python3"
303
+ if runtime_python.is_file():
304
+ candidates.append(str(runtime_python))
305
+ if sys.executable:
306
+ candidates.append(sys.executable)
307
+ python3_path = shutil.which("python3")
308
+ if python3_path:
309
+ candidates.append(python3_path)
310
+ seen: set[str] = set()
311
+ ordered: list[str] = []
312
+ for item in candidates:
313
+ if item and item not in seen:
314
+ seen.add(item)
315
+ ordered.append(item)
316
+ return ordered
317
+
318
+
319
+ def full_disk_access_targets() -> list[str]:
320
+ targets = ["/bin/bash", *(_runtime_python_candidates())]
321
+ seen: set[str] = set()
322
+ ordered: list[str] = []
323
+ for item in targets:
324
+ if item and item not in seen:
325
+ seen.add(item)
326
+ ordered.append(item)
327
+ return ordered
328
+
329
+
330
+ def open_full_disk_access_settings() -> dict:
331
+ if platform.system() != "Darwin":
332
+ return {"ok": False, "opened": False, "message": "Full Disk Access setup is macOS-only."}
333
+ try:
334
+ result = subprocess.run(
335
+ ["open", MACOS_FDA_SETTINGS_URL],
336
+ capture_output=True,
337
+ text=True,
338
+ timeout=5,
339
+ )
340
+ ok = result.returncode == 0
341
+ return {
342
+ "ok": ok,
343
+ "opened": ok,
344
+ "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
345
+ }
346
+ except Exception as e:
347
+ return {"ok": False, "opened": False, "message": str(e)}
348
+
349
+
350
+ def _probe_candidates() -> list[Path]:
351
+ candidates: list[Path] = []
352
+ for path_candidate in MACOS_FDA_PROBE_PATHS:
353
+ expanded = path_candidate.expanduser()
354
+ if expanded.exists():
355
+ candidates.append(expanded)
356
+ if _is_protected_macos_path(NEXO_HOME):
357
+ candidates.append(NEXO_HOME)
358
+ return candidates
359
+
360
+
361
+ def probe_full_disk_access() -> dict:
362
+ if platform.system() != "Darwin":
363
+ return {"checked": False, "granted": None, "probe_path": None, "message": "macOS-only"}
364
+
365
+ candidates = _probe_candidates()
366
+ if not candidates:
367
+ return {
368
+ "checked": False,
369
+ "granted": None,
370
+ "probe_path": None,
371
+ "message": "No local probe path available for verification.",
372
+ }
373
+
374
+ script = 'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi'
375
+ last_error = ""
376
+ for candidate in candidates:
377
+ try:
378
+ result = subprocess.run(
379
+ ["/bin/bash", "-lc", script, "_", str(candidate)],
380
+ capture_output=True,
381
+ text=True,
382
+ timeout=5,
383
+ )
384
+ except Exception as e:
385
+ last_error = str(e)
386
+ continue
387
+ if result.returncode == 0:
388
+ return {
389
+ "checked": True,
390
+ "granted": True,
391
+ "probe_path": str(candidate),
392
+ "message": "",
393
+ }
394
+ last_error = result.stderr.strip() or result.stdout.strip()
395
+ return {
396
+ "checked": True,
397
+ "granted": False,
398
+ "probe_path": str(candidates[0]),
399
+ "message": last_error,
400
+ }
401
+
402
+
403
+ def prompt_for_full_disk_access(
404
+ *,
405
+ reason: str = "install",
406
+ reasons: list[str] | None = None,
407
+ input_fn=input,
408
+ output_fn=print,
409
+ open_fn=open_full_disk_access_settings,
410
+ probe_fn=probe_full_disk_access,
411
+ ) -> dict:
412
+ reasons = normalize_full_disk_access_reasons(reasons)
413
+ output_fn(
414
+ "[NEXO] Some macOS background automations may need Full Disk Access. "
415
+ "macOS does not allow granting it automatically."
416
+ )
417
+ if reasons:
418
+ output_fn("[NEXO] Reason(s) detected:")
419
+ for item in reasons:
420
+ output_fn(f"[NEXO] - {item}")
421
+ output_fn("[NEXO] If you continue, NEXO will open the correct System Settings screen.")
422
+ output_fn("[NEXO] Add your terminal app and, if needed for background jobs, these binaries:")
423
+ for target in full_disk_access_targets():
424
+ output_fn(f"[NEXO] - {target}")
425
+
426
+ prompt = "[NEXO] Open Full Disk Access setup now? [y]es / [n]o / [l]ater: "
427
+ while True:
428
+ answer = str(input_fn(prompt)).strip().lower()
429
+ if answer in {"y", "yes"}:
430
+ open_result = open_fn()
431
+ if open_result.get("opened"):
432
+ output_fn("[NEXO] System Settings opened at Privacy & Security → Full Disk Access.")
433
+ elif open_result.get("message"):
434
+ output_fn(f"[NEXO] Could not open System Settings automatically: {open_result['message']}")
435
+ output_fn("[NEXO] Grant the permission, then press Enter to verify.")
436
+ follow_up = str(
437
+ input_fn("[NEXO] Press Enter after granting it, or type later to skip for now: ")
438
+ ).strip().lower()
439
+ if follow_up in {"later", "l"}:
440
+ return {
441
+ "status": FULL_DISK_ACCESS_LATER,
442
+ "settings_opened": bool(open_result.get("opened")),
443
+ "verified": False,
444
+ "message": "Full Disk Access setup deferred for later.",
445
+ }
446
+ probe = probe_fn()
447
+ if probe.get("granted") is True:
448
+ return {
449
+ "status": FULL_DISK_ACCESS_GRANTED,
450
+ "settings_opened": bool(open_result.get("opened")),
451
+ "verified": True,
452
+ "message": f"Full Disk Access verified via {probe.get('probe_path')}.",
453
+ }
454
+ return {
455
+ "status": FULL_DISK_ACCESS_LATER,
456
+ "settings_opened": bool(open_result.get("opened")),
457
+ "verified": False,
458
+ "message": (
459
+ "Could not verify Full Disk Access yet. NEXO will remind you later if "
460
+ "background jobs still hit TCC."
461
+ ),
462
+ }
463
+ if answer in {"n", "no"}:
464
+ return {
465
+ "status": FULL_DISK_ACCESS_DECLINED,
466
+ "settings_opened": False,
467
+ "verified": False,
468
+ "message": "Full Disk Access was declined.",
469
+ }
470
+ if answer in {"l", "later", ""}:
471
+ return {
472
+ "status": FULL_DISK_ACCESS_LATER,
473
+ "settings_opened": False,
474
+ "verified": False,
475
+ "message": "Full Disk Access setup deferred for later.",
476
+ }
477
+ output_fn("[NEXO] Reply with yes, no, or later.")
478
+
479
+
480
+ def format_power_policy_label(policy: str | None = None, *, system: str | None = None) -> str:
481
+ details = describe_power_policy(policy=policy, system=system)
482
+ policy = details["policy"]
483
+ if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Darwin":
484
+ return "always_on (macOS caffeinate, closed-lid best effort)"
485
+ if policy == POWER_POLICY_ALWAYS_ON and details["platform"] == "Linux":
486
+ helper = details["helper"] or "power helper"
487
+ return f"always_on ({helper}, closed-lid depends on host policy)"
488
+ return policy
489
+
490
+
83
491
  def get_power_policy(schedule: dict | None = None) -> str:
84
492
  schedule = schedule or load_schedule_config()
85
493
  return normalize_power_policy(schedule.get(POWER_POLICY_KEY))
@@ -97,20 +505,33 @@ def set_power_policy(policy: str) -> dict:
97
505
  return schedule
98
506
 
99
507
 
508
+ def set_full_disk_access_status(status: str, *, reasons: list[str] | None = None) -> dict:
509
+ schedule = load_schedule_config()
510
+ schedule[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(status)
511
+ schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
512
+ if reasons is not None:
513
+ schedule[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(reasons)
514
+ save_schedule_config(schedule)
515
+ return schedule
516
+
517
+
100
518
  def prompt_for_power_policy(
101
519
  *,
102
520
  reason: str = "install",
521
+ system: str | None = None,
103
522
  input_fn=input,
104
523
  output_fn=print,
105
524
  ) -> str:
525
+ details = describe_power_policy(POWER_POLICY_ALWAYS_ON, system=system)
106
526
  prompt = (
107
- "[NEXO] Keep this machine awake for background work? "
527
+ "[NEXO] Enable the background power helper for this machine? "
108
528
  "[y]es / [n]o / [l]ater: "
109
529
  )
110
530
  output_fn(
111
531
  "[NEXO] This controls the optional prevent-sleep helper. "
112
- "It improves background availability but should remain opt-in."
532
+ "It improves background availability but remains opt-in."
113
533
  )
534
+ output_fn(f"[NEXO] {details['prompt_note']}")
114
535
  while True:
115
536
  answer = str(input_fn(prompt)).strip().lower()
116
537
  if answer in {"y", "yes"}:
@@ -134,7 +555,12 @@ def ensure_power_policy_choice(
134
555
  prompted = False
135
556
  if interactive and policy == POWER_POLICY_UNSET:
136
557
  prompted = True
137
- policy = prompt_for_power_policy(reason=reason, input_fn=input_fn, output_fn=output_fn)
558
+ policy = prompt_for_power_policy(
559
+ reason=reason,
560
+ system=platform.system(),
561
+ input_fn=input_fn,
562
+ output_fn=output_fn,
563
+ )
138
564
  schedule[POWER_POLICY_KEY] = policy
139
565
  schedule[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
140
566
  save_schedule_config(schedule)
@@ -145,6 +571,99 @@ def ensure_power_policy_choice(
145
571
  }
146
572
 
147
573
 
574
+ def ensure_full_disk_access_choice(
575
+ *,
576
+ interactive: bool,
577
+ reason: str = "update",
578
+ input_fn=input,
579
+ output_fn=print,
580
+ open_fn=open_full_disk_access_settings,
581
+ probe_fn=probe_full_disk_access,
582
+ ) -> dict:
583
+ schedule = load_schedule_config()
584
+ system = platform.system()
585
+ status = get_full_disk_access_status(schedule)
586
+ reasons = detect_full_disk_access_reasons(system=system)
587
+ prompted = False
588
+ verified = False
589
+ settings_opened = False
590
+ message = ""
591
+
592
+ if system != "Darwin":
593
+ return {
594
+ "status": status,
595
+ "prompted": False,
596
+ "verified": False,
597
+ "settings_opened": False,
598
+ "reasons": [],
599
+ "schedule_file": str(SCHEDULE_FILE),
600
+ "message": "",
601
+ "relevant": False,
602
+ }
603
+
604
+ schedule[FULL_DISK_ACCESS_REASONS_KEY] = reasons
605
+ schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
606
+
607
+ if not reasons:
608
+ save_schedule_config(schedule)
609
+ return {
610
+ "status": status,
611
+ "prompted": False,
612
+ "verified": False,
613
+ "settings_opened": False,
614
+ "reasons": [],
615
+ "schedule_file": str(SCHEDULE_FILE),
616
+ "message": "",
617
+ "relevant": False,
618
+ }
619
+
620
+ if status == FULL_DISK_ACCESS_GRANTED:
621
+ probe = probe_fn()
622
+ if probe.get("granted") is True:
623
+ verified = True
624
+ message = f"Full Disk Access verified via {probe.get('probe_path')}."
625
+ else:
626
+ status = FULL_DISK_ACCESS_LATER
627
+ message = (
628
+ "Full Disk Access was configured previously but could not be verified. "
629
+ "NEXO will remind you again on the next interactive update."
630
+ )
631
+
632
+ elif interactive and status in {FULL_DISK_ACCESS_UNSET, FULL_DISK_ACCESS_LATER}:
633
+ prompted = True
634
+ prompt_result = prompt_for_full_disk_access(
635
+ reason=reason,
636
+ reasons=reasons,
637
+ input_fn=input_fn,
638
+ output_fn=output_fn,
639
+ open_fn=open_fn,
640
+ probe_fn=probe_fn,
641
+ )
642
+ status = normalize_full_disk_access_status(prompt_result.get("status"))
643
+ verified = bool(prompt_result.get("verified"))
644
+ settings_opened = bool(prompt_result.get("settings_opened"))
645
+ message = str(prompt_result.get("message") or "")
646
+
647
+ elif status == FULL_DISK_ACCESS_DECLINED:
648
+ message = (
649
+ "Full Disk Access remains declined. Background jobs that touch protected "
650
+ "macOS folders may fail."
651
+ )
652
+
653
+ schedule[FULL_DISK_ACCESS_STATUS_KEY] = status
654
+ save_schedule_config(schedule)
655
+ return {
656
+ "status": status,
657
+ "prompted": prompted,
658
+ "verified": verified,
659
+ "settings_opened": settings_opened,
660
+ "reasons": reasons,
661
+ "schedule_file": str(SCHEDULE_FILE),
662
+ "message": message,
663
+ "relevant": True,
664
+ }
665
+
666
+
148
667
  def _prevent_sleep_script_path() -> Path:
149
668
  runtime_script = NEXO_HOME / "scripts" / "nexo-prevent-sleep.sh"
150
669
  if runtime_script.is_file():
@@ -199,25 +718,37 @@ def apply_power_policy(policy: str | None = None) -> dict:
199
718
  system = platform.system()
200
719
  logs_dir = NEXO_HOME / "logs"
201
720
  logs_dir.mkdir(parents=True, exist_ok=True)
721
+ details = describe_power_policy(policy=policy, system=system)
202
722
 
203
723
  if system == "Darwin":
204
- return _apply_macos_power_policy(policy)
724
+ return _apply_macos_power_policy(policy, details=details)
205
725
  if system == "Linux":
206
- return _apply_linux_power_policy(policy)
726
+ return _apply_linux_power_policy(policy, details=details)
207
727
  return {
208
728
  "ok": policy != POWER_POLICY_ALWAYS_ON,
209
729
  "policy": policy,
210
730
  "platform": system,
211
731
  "action": "unsupported",
212
732
  "message": f"Unsupported platform for prevent-sleep policy: {system}",
733
+ "details": details,
213
734
  }
214
735
 
215
736
 
216
- def _apply_macos_power_policy(policy: str) -> dict:
737
+ def _apply_macos_power_policy(policy: str, *, details: dict | None = None) -> dict:
217
738
  plist_path, plist = _macos_prevent_sleep_plist()
218
739
  label = plist["Label"]
219
740
  uid = str(os.getuid())
220
741
  if policy == POWER_POLICY_ALWAYS_ON:
742
+ details = details or describe_power_policy(policy, system="Darwin")
743
+ if not details.get("helper_available"):
744
+ return {
745
+ "ok": False,
746
+ "policy": policy,
747
+ "platform": "Darwin",
748
+ "action": "missing-helper",
749
+ "message": f"Required helper not found: {details.get('helper_path') or 'caffeinate'}",
750
+ "details": details,
751
+ }
221
752
  LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
222
753
  with plist_path.open("wb") as fh:
223
754
  plistlib.dump(plist, fh)
@@ -235,6 +766,7 @@ def _apply_macos_power_policy(policy: str) -> dict:
235
766
  "action": "enabled",
236
767
  "plist_path": str(plist_path),
237
768
  "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
769
+ "details": details,
238
770
  }
239
771
 
240
772
  subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True)
@@ -247,12 +779,23 @@ def _apply_macos_power_policy(policy: str) -> dict:
247
779
  "platform": "Darwin",
248
780
  "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
249
781
  "plist_path": str(plist_path),
782
+ "details": details or describe_power_policy(policy, system="Darwin"),
250
783
  }
251
784
 
252
785
 
253
- def _apply_linux_power_policy(policy: str) -> dict:
786
+ def _apply_linux_power_policy(policy: str, *, details: dict | None = None) -> dict:
254
787
  service_path, service_body = _linux_prevent_sleep_service()
255
788
  if policy == POWER_POLICY_ALWAYS_ON:
789
+ details = details or describe_power_policy(policy, system="Linux")
790
+ if not details.get("helper_available"):
791
+ return {
792
+ "ok": False,
793
+ "policy": policy,
794
+ "platform": "Linux",
795
+ "action": "missing-helper",
796
+ "message": "No Linux power helper found. Install systemd-inhibit or caffeine.",
797
+ "details": details,
798
+ }
256
799
  LINUX_SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
257
800
  service_path.write_text(service_body)
258
801
  subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
@@ -269,6 +812,7 @@ def _apply_linux_power_policy(policy: str) -> dict:
269
812
  "action": "enabled",
270
813
  "service_path": str(service_path),
271
814
  "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
815
+ "details": details,
272
816
  }
273
817
 
274
818
  subprocess.run(["systemctl", "--user", "disable", "--now", "nexo-prevent-sleep.service"], capture_output=True)
@@ -281,4 +825,5 @@ def _apply_linux_power_policy(policy: str) -> dict:
281
825
  "platform": "Linux",
282
826
  "action": "disabled" if policy == POWER_POLICY_DISABLED else "deferred",
283
827
  "service_path": str(service_path),
828
+ "details": details or describe_power_policy(policy, system="Linux"),
284
829
  }