33and send notifications to the user on predefined conditions.
44"""
55import logging
6- import platform
6+ import sys
77import threading
88from collections import defaultdict
99from datetime import datetime , timedelta , timezone
2323from desktop_notifier import DesktopNotifier
2424from typing_extensions import TypeAlias
2525
26- # TODO: Add thresholds for total time today (incl percentage of productive time)
27-
2826logger = logging .getLogger (__name__ )
2927
28+ # Types
29+ AwClient = aw_client .ActivityWatchClient
30+ CacheKey : TypeAlias = tuple
31+
32+ # Constants
33+ # TODO: Add thresholds for total time today (incl percentage of productive time)
3034# TODO: read from server settings
3135TIME_OFFSET = timedelta (hours = 4 )
3236
33- aw = aw_client .ActivityWatchClient ("aw-notify" , testing = False )
37+ td15min = timedelta (minutes = 15 )
38+ td30min = timedelta (minutes = 30 )
39+ td1h = timedelta (hours = 1 )
40+ td2h = timedelta (hours = 2 )
41+ td6h = timedelta (hours = 6 )
42+ td4h = timedelta (hours = 4 )
43+ td8h = timedelta (hours = 8 )
44+
45+ # global objects
46+ # will init in entrypoints
47+ aw : Optional [AwClient ] = None
48+ notifier : Optional [DesktopNotifier ] = None
49+
50+ # executable path
51+ script_dir = Path (__file__ ).parent .absolute ()
52+ icon_path = script_dir / ".." / "media" / "logo" / "logo.png"
3453
3554
3655def cache_ttl (ttl : Union [timedelta , int ]):
3756 """Decorator that caches the result of a function in-memory, with a given time-to-live."""
3857 T = TypeVar ("T" )
39- CacheKey : TypeAlias = tuple
4058
4159 _ttl : timedelta = ttl if isinstance (ttl , timedelta ) else timedelta (seconds = ttl )
4260
@@ -66,6 +84,7 @@ def get_time(date=None, top_level_only=True) -> dict[str, timedelta]:
6684 """
6785 Returns a dict with the time spent today (or for `date`) for each category.
6886 """
87+ assert aw
6988
7089 if date is None :
7190 date = datetime .now (timezone .utc )
@@ -120,16 +139,8 @@ def to_hms(duration: timedelta) -> str:
120139 return s .strip ()
121140
122141
123- notifier : DesktopNotifier = None
124-
125- # executable path
126-
127- script_dir = Path (__file__ ).parent .absolute ()
128- icon_path = script_dir / ".." / "media" / "logo" / "logo.png"
129-
130-
131142def notify (title : str , msg : str ):
132- # send a notification to the user
143+ """ send a notification to the user"""
133144
134145 global notifier
135146 if notifier is None :
@@ -143,15 +154,6 @@ def notify(title: str, msg: str):
143154 notifier .send_sync (title = title , message = msg )
144155
145156
146- td15min = timedelta (minutes = 15 )
147- td30min = timedelta (minutes = 30 )
148- td1h = timedelta (hours = 1 )
149- td2h = timedelta (hours = 2 )
150- td6h = timedelta (hours = 6 )
151- td4h = timedelta (hours = 4 )
152- td8h = timedelta (hours = 8 )
153-
154-
155157class CategoryAlert :
156158 """
157159 Alerts for a category.
@@ -242,6 +244,19 @@ def test_category_alert():
242244 catalert .check ()
243245
244246
247+ def init_macos ():
248+ # set NSBundle on macOS, needed for desktop-notifier
249+ from rubicon .objc import ObjCClass
250+
251+ logger .info ("Setting NSBundle for macOS" )
252+
253+ # needs to be the same as the one in the PyInstaller config (later Info.plist)
254+ # could also be done by just monkey-patching the helper function is_signed_bundle in desktop-notifier
255+ # see: https://github.com/samschott/desktop-notifier/issues/115
256+ NSBundle = ObjCClass ("NSBundle" )
257+ NSBundle .mainBundle .bundleIdentifier = "net.activitywatch.ActivityWatch"
258+
259+
245260@click .group (invoke_without_command = True )
246261@click .pass_context
247262@click .option ("-v" , "--verbose" , is_flag = True , help = "Verbose logging." )
@@ -251,23 +266,20 @@ def main(ctx, verbose: bool, testing: bool):
251266 logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
252267 logger .info ("Starting..." )
253268
254- # set NSBundle on macOS, needed for desktop-notifier
255- if platform .system () == "Darwin" :
256- from rubicon .objc import ObjCClass
257-
258- # needs to be the same as the one in the PyInstaller config (later Info.plist)
259- # could also be done by just monkey-patching the helper function is_signed_bundle in desktop-notifier
260- # see: https://github.com/samschott/desktop-notifier/issues/115
261- NSBundle = ObjCClass ("NSBundle" )
262- NSBundle .mainBundle .bundleIdentifier = "net.activitywatch.ActivityWatch"
269+ if sys .platform == "darwin" :
270+ init_macos ()
263271
264272 if ctx .invoked_subcommand is None :
265- ctx .invoke (start )
273+ ctx .invoke (start , testing = testing )
266274
267275
268276@main .command ()
269- def start ():
277+ @click .option ("--testing" , is_flag = True , help = "Enables testing mode." )
278+ def start (testing = False ):
270279 """Start the notification service."""
280+ global aw
281+ aw = aw_client .ActivityWatchClient ("aw-notify" , testing = testing )
282+
271283 send_checkin ()
272284 send_checkin_yesterday ()
273285 start_hourly ()
@@ -301,14 +313,19 @@ def threshold_alerts():
301313 status = alert .status ()
302314 if status != getattr (alert , "last_status" , None ):
303315 logger .debug (f"New status: { status } " )
304- alert . last_status = status
316+ setattr ( alert , " last_status" , status )
305317
318+ # TODO: make configurable, perhaps increase default to save resources
306319 sleep (10 )
307320
308321
309322@main .command ()
310- def checkin ():
323+ @click .option ("--testing" , is_flag = True , help = "Enables testing mode." )
324+ def checkin (testing = False ):
311325 """Send a summary notification."""
326+ global aw
327+ aw = aw_client .ActivityWatchClient ("aw-notify-checkin" , testing = testing )
328+
312329 send_checkin ()
313330
314331
@@ -347,6 +364,7 @@ def get_active_status() -> Union[bool, None]:
347364 Returns True if user is active/not-afk, False if not.
348365 On error, like out-of-date event, returns None.
349366 """
367+ assert aw
350368
351369 hostname = aw .get_info ().get ("hostname" , "unknown" )
352370 events = aw .get_events (f"aw-watcher-afk_{ hostname } " , limit = 1 )
0 commit comments