Skip to content

Commit 9e5c9b7

Browse files
author
tom
committed
release mac test a'gain
1 parent e421acd commit 9e5c9b7

5 files changed

Lines changed: 351 additions & 237 deletions

File tree

README_macOS.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Detailed macOS-specific setup for **PyCron Video Alarm Manager**.
2020
```bash
2121
brew install brightness
2222
```
23-
Without it, brightness actions are silently skipped.
23+
If not installed, the app automatically falls back to a `swift` one-liner using CoreGraphics (requires Xcode Command Line Tools: `xcode-select --install`). Brightness actions are never crash — they log a warning and skip if neither tool is available.
2424

2525
## 🚀 Setting Up the Application
2626

@@ -49,26 +49,36 @@ python3 src/main.py
4949
```
5050

5151
## ⏰ Scheduling Alarms
52-
On macOS, scheduling relies on the underlying `cron` daemon.
52+
On macOS, scheduling uses **launchd**the native macOS task scheduler — via `~/Library/LaunchAgents/` plist files. No crontab setup, no Full Disk Access permission required.
5353

54-
- Alarms are created as standard cron jobs inside your user's crontab.
55-
- Ensure your Terminal (or the IDE/app running Python) has **Full Disk Access** in **System Settings > Privacy & Security > Full Disk Access** so it can modify the crontab without permission errors.
56-
- Test your terminal's crontab access:
54+
- Each alarm is stored as a `.plist` file under `~/Library/LaunchAgents/com.juke32.pycronvideoalarm.*.plist`.
55+
- Alarms are managed automatically by the app (add, remove, list).
56+
- One-time alarms automatically delete their plist after firing.
57+
- To inspect your alarms manually:
5758
```bash
58-
crontab -l
59+
ls ~/Library/LaunchAgents/com.juke32.pycronvideoalarm.*.plist
60+
```
61+
- To remove all alarms manually (emergency reset):
62+
```bash
63+
for f in ~/Library/LaunchAgents/com.juke32.pycronvideoalarm.*.plist; do
64+
launchctl unload "$f" && rm "$f"
65+
done
5966
```
6067

6168
## 😴 Sleep and Power Settings
62-
- Alarms require the system to be awake to execute properly. You may need to adjust your Energy Saver/Displays settings or use a tool like Amphetamine if you want alarms to trigger without the Mac sleeping fully.
69+
- During alarm playback the app uses `caffeinate` (built-in to macOS) to prevent the system from sleeping.
70+
- If you need the Mac to wake from deep sleep to fire an alarm, enable **System Settings → Displays → Prevent automatic sleeping when the display is off** or use a third-party tool like **Amphetamine**.
6371

6472
---
6573

6674
## 🛠️ Troubleshooting
6775

6876
| Problem | Solution |
6977
|---|---|
70-
| Alarm didn't fire | Check the **Next Alarm** ticker in the Alarms tab. Verify your terminal has Full Disk Access for crontab. |
71-
| Video won't play | Ensure MPV or VLC is installed via Homebrew. |
78+
| Alarm didn't fire | Ensure the Mac was awake at alarm time. Check `~/Library/LaunchAgents/` for your plist and `launchctl list \| grep pycronvideoalarm`. |
79+
| Brightness does nothing | Install `brew install brightness` OR `xcode-select --install` for the swift fallback. |
80+
| Video won't play | Ensure VLC is installed: `brew install --cask vlc`. |
81+
| Wrong file paths | Ensure `video/`, `audio/`, and `sequences/` folders are in the **same folder as the `.app` bundle**, not inside it. |
7282

7383
Enable **Settings → Logging** and check the logs folder for detailed error output.
7484

src/main.py

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,12 @@ def main():
6565
# Frozen: executable lives in dist/ (Linux/Win) or App.app/Contents/MacOS/ (macOS)
6666
exe_path = os.path.abspath(sys.executable)
6767
if sys.platform == 'darwin' and '.app/Contents/MacOS' in exe_path:
68-
# Walk up: MacOS -> Contents -> App.app -> parent folder (where video/, audio/ live)
69-
project_root = os.path.dirname(os.path.dirname(os.path.dirname(exe_path)))
68+
# sys.executable = /path/to/App.app/Contents/MacOS/PyCronVideoAlarm
69+
# dirname x4 reaches the folder containing the .app (where video/, audio/ live)
70+
project_root = os.path.dirname( # /path/to/
71+
os.path.dirname( # App.app
72+
os.path.dirname( # Contents
73+
os.path.dirname(exe_path)))) # MacOS
7074
else:
7175
project_root = os.path.dirname(exe_path)
7276
else:
@@ -124,16 +128,59 @@ def main():
124128

125129
# Delete if requested
126130
if args.delete_after:
127-
logging.info(f"Attempting to delete one-time cron job for '{args.execute_sequence}'...")
131+
logging.info(f"Attempting to delete one-time job for '{args.execute_sequence}'...")
128132
try:
129133
import os
130-
if sys.platform.startswith('linux') or sys.platform == 'darwin':
131-
# Directly remove the cron job by matching our unique ID + marker
134+
if sys.platform == 'darwin':
135+
# macOS: remove the launchd plist identified by job-id
136+
try:
137+
import glob
138+
import plistlib
139+
import subprocess as _sp
140+
plist_dir = os.path.expanduser("~/Library/LaunchAgents")
141+
label_prefix = "com.juke32.pycronvideoalarm"
142+
deleted = False
143+
144+
if args.job_id:
145+
plist_file = os.path.join(plist_dir, f"{label_prefix}.{args.job_id}.plist")
146+
if os.path.exists(plist_file):
147+
_sp.run(["launchctl", "unload", plist_file],
148+
capture_output=True)
149+
os.remove(plist_file)
150+
logging.info(f"Deleted launchd plist by job-id: {args.job_id}")
151+
deleted = True
152+
153+
if not deleted:
154+
# Fallback: match by sequence name in plist EnvironmentVariables
155+
for plist_file in glob.glob(
156+
os.path.join(plist_dir, f"{label_prefix}.*.plist")):
157+
try:
158+
with open(plist_file, "rb") as f:
159+
plist = plistlib.load(f)
160+
env = plist.get("EnvironmentVariables", {})
161+
if (env.get("PCVA_SEQUENCE") == args.execute_sequence
162+
and env.get("PCVA_ONE_TIME") == "1"):
163+
_sp.run(["launchctl", "unload", plist_file],
164+
capture_output=True)
165+
os.remove(plist_file)
166+
logging.info(f"Deleted launchd plist by sequence name: {plist_file}")
167+
deleted = True
168+
break
169+
except Exception:
170+
continue
171+
172+
if not deleted:
173+
logging.warning("Could not find launchd plist to delete.")
174+
except Exception as e:
175+
logging.error(f"Failed to delete launchd plist: {e}")
176+
177+
elif sys.platform.startswith('linux'):
178+
# Linux: remove cron job by matching our unique ID + marker
132179
try:
133180
from crontab import CronTab
134181
cron = CronTab(user=True)
135182
job_deleted = False
136-
183+
137184
# Strategy 1: Unique Job ID (Best)
138185
if args.job_id:
139186
target_comment = f"#PyCronVideoAlarm:{args.job_id}"
@@ -143,51 +190,33 @@ def main():
143190
if base_cmd in str(job.command):
144191
cron.remove(job)
145192
job_deleted = True
146-
logging.info(f"Deleted specific cron job by ID {args.job_id}")
193+
logging.info(f"Deleted cron job by ID {args.job_id}")
147194
break
148-
149-
# Strategy 2: Time-based Match (User Request / Fallback)
195+
196+
# Strategy 2: Time-based Match
150197
if not job_deleted and args.scheduled_time:
151-
logging.info(f"ID match failed/missing. Trying time match for '{args.scheduled_time}'...")
152198
try:
153199
target_h, target_m = map(int, args.scheduled_time.split(':'))
154200
for job in cron:
155-
# Match marker prefix
156-
is_our_job = job.comment and (job.comment == "#PyCronVideoAlarm" or job.comment.startswith("#PyCronVideoAlarm:"))
157-
158-
if (is_our_job and
159-
args.execute_sequence in str(job.command) and
160-
"--delete-after" in str(job.command)):
161-
162-
# Check time
163-
if job.hour == target_h and job.minute == target_m:
164-
cron.remove(job)
165-
job_deleted = True
166-
logging.info(f"Deleted cron job by Time Match ({args.scheduled_time})")
167-
break
201+
is_our_job = job.comment and (
202+
job.comment == "#PyCronVideoAlarm"
203+
or job.comment.startswith("#PyCronVideoAlarm:"))
204+
if (is_our_job
205+
and args.execute_sequence in str(job.command)
206+
and "--delete-after" in str(job.command)
207+
and job.hour == target_h
208+
and job.minute == target_m):
209+
cron.remove(job)
210+
job_deleted = True
211+
logging.info(f"Deleted cron job by time match")
212+
break
168213
except Exception as e:
169214
logging.warning(f"Time match logic error: {e}")
170215

171-
# Strategy 3: Legacy Soft Match (Last Resort)
172-
# ONLY if we absolutely missed the specific ways (e.g. old version of scheduler but new main.py)
173-
if not job_deleted and not args.scheduled_time and not args.job_id:
174-
logging.info("No ID/Time provided. Falling back to UNSAFE soft match (First Match)...")
175-
for job in cron:
176-
is_our_job = job.comment and (job.comment == "#PyCronVideoAlarm" or job.comment.startswith("#PyCronVideoAlarm:"))
177-
178-
if (is_our_job and
179-
args.execute_sequence in str(job.command) and
180-
"--delete-after" in str(job.command)):
181-
182-
cron.remove(job)
183-
logging.info(f"Deleted cron job via Soft Match (Unsafe): {str(job.command)[:80]}")
184-
break
185-
186216
if job_deleted:
187217
cron.write()
188218
else:
189219
logging.warning("Could not find matching cron job to delete.")
190-
191220
except Exception as e:
192221
logging.error(f"Failed to delete cron job: {e}")
193222
else:

src/platforms/macos/display.py

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,107 @@
11
import subprocess
22
import logging
3+
import shutil
4+
import os
35
from core.interfaces import DisplayManager
46

57

68
class MacOSDisplayManager(DisplayManager):
79
"""macOS display manager.
810
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
1115
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.
1618
"""
1719

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)."""
2022
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
2325
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, ""
2628
except FileNotFoundError:
2729
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
2943

3044
# ------------------------------------------------------------------
3145
# Display power
3246
# ------------------------------------------------------------------
3347

3448
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
3751

3852
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
4155

4256
# ------------------------------------------------------------------
4357
# Brightness
4458
# ------------------------------------------------------------------
4559

4660
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."""
5462
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")
5983
return True
6084

85+
# Strategy 3: graceful no-op
6186
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"
6490
)
6591
return False
6692

6793
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
83107
return 100 # Safe default

0 commit comments

Comments
 (0)