|
1 | 1 | import subprocess |
2 | 2 | import logging |
| 3 | +import shutil |
| 4 | +import os |
3 | 5 | from core.interfaces import DisplayManager |
4 | 6 |
|
5 | 7 |
|
6 | 8 | class MacOSDisplayManager(DisplayManager): |
7 | 9 | """macOS display manager. |
8 | 10 |
|
9 | | - Brightness control requires the 'brightness' CLI tool: |
10 | | - brew install brightness |
| 11 | + Brightness control is attempted via multiple strategies in order: |
| 12 | + 1. 'brightness' CLI (Homebrew: brew install brightness) — Intel + Apple Silicon |
| 13 | + 2. swift one-liner via CoreBrightness framework — requires Xcode CLT |
| 14 | + 3. Graceful no-op — logs warning, never crashes |
11 | 15 |
|
12 | | - If it is not installed, brightness calls log a warning and return False |
13 | | - silently — no crash, no error dialog. |
14 | | -
|
15 | | - Display sleep / wake uses pmset which ships with macOS. |
| 16 | + Display sleep/wake uses pmset (ships with macOS, no install needed). |
| 17 | + No Accessibility permissions required for any of these strategies. |
16 | 18 | """ |
17 | 19 |
|
18 | | - def _run(self, cmd): |
19 | | - """Run a command. Returns True on success.""" |
| 20 | + def _run(self, cmd, **kwargs): |
| 21 | + """Run a command. Returns (success, stdout).""" |
20 | 22 | try: |
21 | | - subprocess.run(cmd, check=True, capture_output=True) |
22 | | - return True |
| 23 | + result = subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs) |
| 24 | + return True, result.stdout |
23 | 25 | except subprocess.CalledProcessError as e: |
24 | | - logging.debug(f"Command failed: {' '.join(cmd)} — {e}") |
25 | | - return False |
| 26 | + logging.debug(f"Command failed {cmd[0]}: {e.stderr.strip()}") |
| 27 | + return False, "" |
26 | 28 | except FileNotFoundError: |
27 | 29 | logging.debug(f"Command not found: {cmd[0]}") |
28 | | - return False |
| 30 | + return False, "" |
| 31 | + |
| 32 | + def _find_brightness_bin(self): |
| 33 | + """Locate the 'brightness' CLI in common Homebrew paths.""" |
| 34 | + candidates = [ |
| 35 | + shutil.which("brightness"), |
| 36 | + "/opt/homebrew/bin/brightness", # Apple Silicon Homebrew |
| 37 | + "/usr/local/bin/brightness", # Intel Homebrew |
| 38 | + ] |
| 39 | + for p in candidates: |
| 40 | + if p and os.path.isfile(p) and os.access(p, os.X_OK): |
| 41 | + return p |
| 42 | + return None |
29 | 43 |
|
30 | 44 | # ------------------------------------------------------------------ |
31 | 45 | # Display power |
32 | 46 | # ------------------------------------------------------------------ |
33 | 47 |
|
34 | 48 | def turn_off(self) -> bool: |
35 | | - """Put the display to sleep immediately.""" |
36 | | - return self._run(["pmset", "displaysleepnow"]) |
| 49 | + ok, _ = self._run(["pmset", "displaysleepnow"]) |
| 50 | + return ok |
37 | 51 |
|
38 | 52 | def turn_on(self) -> bool: |
39 | | - """Wake the display (brief caffeinate wakeup assertion).""" |
40 | | - return self._run(["caffeinate", "-u", "-t", "1"]) |
| 53 | + ok, _ = self._run(["caffeinate", "-u", "-t", "1"]) |
| 54 | + return ok |
41 | 55 |
|
42 | 56 | # ------------------------------------------------------------------ |
43 | 57 | # Brightness |
44 | 58 | # ------------------------------------------------------------------ |
45 | 59 |
|
46 | 60 | def set_brightness(self, level: int) -> bool: |
47 | | - """Set screen brightness (0-100) via the 'brightness' Homebrew CLI. |
48 | | -
|
49 | | - If 'brightness' is not installed the call returns False and logs a |
50 | | - warning — it does NOT raise an exception or show an error dialog. |
51 | | -
|
52 | | - Install with: brew install brightness |
53 | | - """ |
| 61 | + """Set screen brightness 0-100 using the best available strategy.""" |
54 | 62 | level = max(0, min(100, int(level))) |
55 | | - brightness_val = f"{level / 100.0:.2f}" |
56 | | - |
57 | | - if self._run(["brightness", brightness_val]): |
58 | | - logging.info(f"Brightness set to {level}% via 'brightness' CLI") |
| 63 | + frac = f"{level / 100.0:.4f}" |
| 64 | + |
| 65 | + # Strategy 1: 'brightness' CLI (Homebrew) |
| 66 | + bin_path = self._find_brightness_bin() |
| 67 | + if bin_path: |
| 68 | + ok, _ = self._run([bin_path, frac]) |
| 69 | + if ok: |
| 70 | + logging.info(f"Brightness → {level}% via 'brightness' CLI") |
| 71 | + return True |
| 72 | + |
| 73 | + # Strategy 2: swift one-liner via CoreBrightness |
| 74 | + # Requires: xcode-select --install (no Accessibility perms needed) |
| 75 | + swift_src = ( |
| 76 | + "import Foundation\n" |
| 77 | + "import CoreGraphics\n" |
| 78 | + f"CGDisplaySetDisplayBrightness(CGMainDisplayID(), {frac})" |
| 79 | + ) |
| 80 | + ok, _ = self._run(["swift", "-"], input=swift_src) |
| 81 | + if ok: |
| 82 | + logging.info(f"Brightness → {level}% via swift/CoreGraphics") |
59 | 83 | return True |
60 | 84 |
|
| 85 | + # Strategy 3: graceful no-op |
61 | 86 | logging.warning( |
62 | | - "macOS brightness control unavailable. " |
63 | | - "Install with: brew install brightness" |
| 87 | + f"macOS brightness control unavailable (level={level}). " |
| 88 | + "Install 'brightness': brew install brightness " |
| 89 | + " — OR — install Xcode CLT: xcode-select --install" |
64 | 90 | ) |
65 | 91 | return False |
66 | 92 |
|
67 | 93 | def get_brightness(self) -> int: |
68 | | - """Get current brightness (0-100) via the 'brightness' CLI.""" |
69 | | - try: |
70 | | - result = subprocess.run( |
71 | | - ["brightness", "-l"], |
72 | | - capture_output=True, |
73 | | - text=True |
74 | | - ) |
75 | | - # Output contains lines like: "display 0: brightness 0.7500" |
76 | | - for line in result.stdout.splitlines(): |
77 | | - if "brightness" in line: |
78 | | - parts = line.strip().split() |
79 | | - val = float(parts[-1]) |
80 | | - return int(val * 100) |
81 | | - except Exception: |
82 | | - pass |
| 94 | + """Get current brightness (0-100). Returns 100 if unavailable.""" |
| 95 | + # Try 'brightness' CLI |
| 96 | + bin_path = self._find_brightness_bin() |
| 97 | + if bin_path: |
| 98 | + ok, out = self._run([bin_path, "-l"]) |
| 99 | + if ok: |
| 100 | + for line in out.splitlines(): |
| 101 | + if "brightness" in line: |
| 102 | + try: |
| 103 | + val = float(line.strip().split()[-1]) |
| 104 | + return int(val * 100) |
| 105 | + except (ValueError, IndexError): |
| 106 | + pass |
83 | 107 | return 100 # Safe default |
0 commit comments