quickmacapp 2025.3.18__py3-none-any.whl → 2025.4.15__py3-none-any.whl
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.
- quickmacapp/_notifications.py +498 -0
- quickmacapp/_quickapp.py +1 -1
- quickmacapp/notifications.py +276 -0
- {quickmacapp-2025.3.18.dist-info → quickmacapp-2025.4.15.dist-info}/METADATA +4 -2
- quickmacapp-2025.4.15.dist-info/RECORD +12 -0
- {quickmacapp-2025.3.18.dist-info → quickmacapp-2025.4.15.dist-info}/WHEEL +1 -1
- quickmacapp-2025.3.18.dist-info/RECORD +0 -10
- {quickmacapp-2025.3.18.dist-info → quickmacapp-2025.4.15.dist-info/licenses}/LICENSE +0 -0
- {quickmacapp-2025.3.18.dist-info → quickmacapp-2025.4.15.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Awaitable, Callable, Protocol, TypeAlias
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
from datetype import DateTime
|
|
9
|
+
from Foundation import (
|
|
10
|
+
NSTimeZone,
|
|
11
|
+
NSDateComponents,
|
|
12
|
+
NSError,
|
|
13
|
+
NSLog,
|
|
14
|
+
NSObject,
|
|
15
|
+
NSCalendar,
|
|
16
|
+
NSCalendarIdentifierGregorian,
|
|
17
|
+
)
|
|
18
|
+
from objc import object_property
|
|
19
|
+
from twisted.internet.defer import Deferred
|
|
20
|
+
from UserNotifications import (
|
|
21
|
+
UNAuthorizationOptionNone,
|
|
22
|
+
UNCalendarNotificationTrigger,
|
|
23
|
+
UNMutableNotificationContent,
|
|
24
|
+
UNNotification,
|
|
25
|
+
UNNotificationAction,
|
|
26
|
+
UNNotificationActionOptionAuthenticationRequired,
|
|
27
|
+
UNNotificationActionOptionDestructive,
|
|
28
|
+
UNNotificationActionOptionForeground,
|
|
29
|
+
UNNotificationActionOptions,
|
|
30
|
+
UNNotificationCategory,
|
|
31
|
+
UNNotificationCategoryOptionAllowInCarPlay,
|
|
32
|
+
UNNotificationCategoryOptionHiddenPreviewsShowSubtitle,
|
|
33
|
+
UNNotificationCategoryOptionHiddenPreviewsShowTitle,
|
|
34
|
+
UNNotificationCategoryOptions,
|
|
35
|
+
UNNotificationPresentationOptionBanner,
|
|
36
|
+
UNNotificationPresentationOptions,
|
|
37
|
+
UNNotificationRequest,
|
|
38
|
+
UNNotificationResponse,
|
|
39
|
+
UNNotificationSettings,
|
|
40
|
+
UNNotificationTrigger,
|
|
41
|
+
UNTextInputNotificationAction,
|
|
42
|
+
UNUserNotificationCenter,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def make[T: NSObject](cls: type[T], **attributes: object) -> T:
|
|
47
|
+
self: T = cls.alloc().init()
|
|
48
|
+
self.setValuesForKeysWithDictionary_(attributes)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _AppNotificationsCtxBuilder:
|
|
54
|
+
_center: UNUserNotificationCenter
|
|
55
|
+
_cfg: _NotifConfigImpl | None
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> _NotifConfigImpl:
|
|
58
|
+
"""
|
|
59
|
+
Request authorization, then start building this notifications manager.
|
|
60
|
+
"""
|
|
61
|
+
NSLog("beginning build")
|
|
62
|
+
grantDeferred: Deferred[bool] = Deferred()
|
|
63
|
+
|
|
64
|
+
def completed(granted: bool, error: NSError | None) -> None:
|
|
65
|
+
# TODO: convert non-None NSErrors into failures on this Deferred
|
|
66
|
+
grantDeferred.callback(granted)
|
|
67
|
+
NSLog(
|
|
68
|
+
"Notification authorization response: %@ with error: %@", granted, error
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
NSLog("requesting authorization")
|
|
72
|
+
self._center.requestAuthorizationWithOptions_completionHandler_(
|
|
73
|
+
UNAuthorizationOptionNone, completed
|
|
74
|
+
)
|
|
75
|
+
NSLog("requested")
|
|
76
|
+
granted = await grantDeferred
|
|
77
|
+
settingsDeferred: Deferred[UNNotificationSettings] = Deferred()
|
|
78
|
+
|
|
79
|
+
def gotSettings(settings: UNNotificationSettings) -> None:
|
|
80
|
+
NSLog("received notification settings %@", settings)
|
|
81
|
+
settingsDeferred.callback(settings)
|
|
82
|
+
|
|
83
|
+
NSLog("requesting notification settings")
|
|
84
|
+
self._center.getNotificationSettingsWithCompletionHandler_(gotSettings)
|
|
85
|
+
settings = await settingsDeferred
|
|
86
|
+
NSLog("initializing config")
|
|
87
|
+
self.cfg = _NotifConfigImpl(
|
|
88
|
+
self._center,
|
|
89
|
+
[],
|
|
90
|
+
_wasGrantedPermission=granted,
|
|
91
|
+
_settings=settings,
|
|
92
|
+
)
|
|
93
|
+
NSLog("done!")
|
|
94
|
+
return self.cfg
|
|
95
|
+
|
|
96
|
+
async def __aexit__(
|
|
97
|
+
self,
|
|
98
|
+
exc_type: type[BaseException] | None,
|
|
99
|
+
exc_value: BaseException | None,
|
|
100
|
+
traceback: TracebackType | None,
|
|
101
|
+
/,
|
|
102
|
+
) -> bool:
|
|
103
|
+
"""
|
|
104
|
+
Finalize the set of notification categories and actions in use for this application.
|
|
105
|
+
"""
|
|
106
|
+
NSLog("async exit from ctx manager")
|
|
107
|
+
if traceback is None and self.cfg is not None:
|
|
108
|
+
qmandw = _QMANotificationDelegateWrapper.alloc().initWithConfig_(self.cfg)
|
|
109
|
+
qmandw.retain()
|
|
110
|
+
NSLog("Setting delegate! %@", qmandw)
|
|
111
|
+
self._center.setDelegate_(qmandw)
|
|
112
|
+
self.cfg._register()
|
|
113
|
+
else:
|
|
114
|
+
NSLog("NOT setting delegate!!!")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _QMANotificationDelegateWrapper(NSObject):
|
|
119
|
+
"""
|
|
120
|
+
UNUserNotificationCenterDelegate implementation.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
config: _NotifConfigImpl = object_property()
|
|
124
|
+
|
|
125
|
+
def initWithConfig_(self, cfg: _NotifConfigImpl) -> _QMANotificationDelegateWrapper:
|
|
126
|
+
self.config = cfg
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def userNotificationCenter_willPresentNotification_withCompletionHandler_(
|
|
130
|
+
self,
|
|
131
|
+
notificationCenter: UNUserNotificationCenter,
|
|
132
|
+
notification: UNNotification,
|
|
133
|
+
completionHandler: Callable[[UNNotificationPresentationOptions], None],
|
|
134
|
+
) -> None:
|
|
135
|
+
NSLog("willPresent: %@", notification)
|
|
136
|
+
# TODO: allow for client code to customize this; here we are saying
|
|
137
|
+
# "please present the notification to the user as a banner, even if we
|
|
138
|
+
# are in the foreground". We should allow for customization on a
|
|
139
|
+
# category basis; rather than @response.something, maybe
|
|
140
|
+
# @present.something, as a method on the python category class?
|
|
141
|
+
completionHandler(UNNotificationPresentationOptionBanner)
|
|
142
|
+
|
|
143
|
+
def userNotificationCenter_didReceiveNotificationResponse_withCompletionHandler_(
|
|
144
|
+
self,
|
|
145
|
+
notificationCenter: UNUserNotificationCenter,
|
|
146
|
+
notificationResponse: UNNotificationResponse,
|
|
147
|
+
completionHandler: Callable[[], None],
|
|
148
|
+
) -> None:
|
|
149
|
+
"""
|
|
150
|
+
We received a response to a notification.
|
|
151
|
+
"""
|
|
152
|
+
NSLog("received notification repsonse %@", notificationResponse)
|
|
153
|
+
|
|
154
|
+
# TODO: actually hook up the dispatch of the notification response to
|
|
155
|
+
# the registry of action callbacks already set up in
|
|
156
|
+
# NotificationConfig.
|
|
157
|
+
async def respond() -> None:
|
|
158
|
+
notifier = self.config._notifierByCategory(
|
|
159
|
+
notificationResponse.notification()
|
|
160
|
+
.request()
|
|
161
|
+
.content()
|
|
162
|
+
.categoryIdentifier()
|
|
163
|
+
)
|
|
164
|
+
await notifier._handleResponse(notificationResponse)
|
|
165
|
+
completionHandler()
|
|
166
|
+
|
|
167
|
+
Deferred.fromCoroutine(respond()).addErrback(
|
|
168
|
+
lambda error: NSLog("error: %@", error)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class NotificationTranslator[T](Protocol):
|
|
173
|
+
"""
|
|
174
|
+
Translate notifications from the notification ID and some user data,
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def fromNotification(self, notificationID: str, userData: dict[str, Any]) -> T:
|
|
178
|
+
"""
|
|
179
|
+
A user interacted with a notification with the given parameters;
|
|
180
|
+
deserialize them into a Python object that can process that action.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def toNotification(self, notification: T) -> tuple[str, dict[str, Any]]:
|
|
184
|
+
"""
|
|
185
|
+
The application has requested to send a notification to the operating
|
|
186
|
+
system, serialize the Python object represneting this category of
|
|
187
|
+
notification into a 2-tuple of C{notificatcionID}, C{userData} that can
|
|
188
|
+
be encapsulated in a C{UNNotificationRequest}.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class Notifier[NotifT]:
|
|
194
|
+
"""
|
|
195
|
+
A notifier for a specific category.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
# Public interface:
|
|
199
|
+
def undeliver(self, notification: NotifT) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Remove the previously-delivered notification object from the
|
|
202
|
+
notification center, if it's still there.
|
|
203
|
+
"""
|
|
204
|
+
notID, _ = self._tx.toNotification(notification)
|
|
205
|
+
self._cfg._center.removeDeliveredNotificationsWithIdentifiers_([notID])
|
|
206
|
+
|
|
207
|
+
def unsend(self, notification: NotifT) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Prevent the as-yet undelivered notification object from being
|
|
210
|
+
delivered.
|
|
211
|
+
"""
|
|
212
|
+
notID, _ = self._tx.toNotification(notification)
|
|
213
|
+
self._cfg._center.removePendingNotificationRequestsWithIdentifiers_([notID])
|
|
214
|
+
|
|
215
|
+
async def notifyAt(
|
|
216
|
+
self, when: DateTime[ZoneInfo], notification: NotifT, title: str, body: str
|
|
217
|
+
) -> None:
|
|
218
|
+
|
|
219
|
+
components: NSDateComponents = NSDateComponents.alloc().init()
|
|
220
|
+
components.setCalendar_(
|
|
221
|
+
NSCalendar.calendarWithIdentifier_(NSCalendarIdentifierGregorian)
|
|
222
|
+
)
|
|
223
|
+
components.setTimeZone_(NSTimeZone.timeZoneWithName_(when.tzinfo.key))
|
|
224
|
+
components.setYear_(when.year)
|
|
225
|
+
components.setMonth_(when.month)
|
|
226
|
+
components.setDay_(when.day)
|
|
227
|
+
components.setHour_(when.hour)
|
|
228
|
+
components.setMinute_(when.minute)
|
|
229
|
+
components.setSecond_(when.second)
|
|
230
|
+
|
|
231
|
+
repeats: bool = False
|
|
232
|
+
trigger: UNNotificationTrigger = (
|
|
233
|
+
UNCalendarNotificationTrigger.triggerWithDateMatchingComponents_repeats_(
|
|
234
|
+
components,
|
|
235
|
+
repeats,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
await self._notifyWithTrigger(trigger, notification, title, body)
|
|
239
|
+
|
|
240
|
+
# Attributes:
|
|
241
|
+
_notificationCategoryID: str
|
|
242
|
+
_cfg: _NotifConfigImpl
|
|
243
|
+
_tx: NotificationTranslator[NotifT]
|
|
244
|
+
_actionInfos: list[_oneActionInfo]
|
|
245
|
+
_allowInCarPlay: bool
|
|
246
|
+
_hiddenPreviewsShowTitle: bool
|
|
247
|
+
_hiddenPreviewsShowSubtitle: bool
|
|
248
|
+
|
|
249
|
+
# Private implementation details:
|
|
250
|
+
async def _handleResponse(self, response: UNNotificationResponse) -> None:
|
|
251
|
+
userInfo = response.notification().request().content().userInfo()
|
|
252
|
+
actionID: str = response.actionIdentifier()
|
|
253
|
+
notificationID: str = response.notification().request().identifier()
|
|
254
|
+
cat = self._tx.fromNotification(notificationID, userInfo)
|
|
255
|
+
for cb, eachActionID, action, options in self._actionInfos:
|
|
256
|
+
if actionID == eachActionID:
|
|
257
|
+
break
|
|
258
|
+
else:
|
|
259
|
+
raise KeyError(actionID)
|
|
260
|
+
await cb(cat, response)
|
|
261
|
+
|
|
262
|
+
def _createUNNotificationCategory(self) -> UNNotificationCategory:
|
|
263
|
+
actions = []
|
|
264
|
+
# We don't yet support intent identifiers.
|
|
265
|
+
intentIdentifiers: list[str] = []
|
|
266
|
+
options = 0
|
|
267
|
+
for handler, actionID, toRegister, extraOptions in self._actionInfos:
|
|
268
|
+
options |= extraOptions
|
|
269
|
+
if toRegister is not None:
|
|
270
|
+
actions.append(toRegister)
|
|
271
|
+
NSLog("actions generated: %@ options: %@", actions, options)
|
|
272
|
+
if self._allowInCarPlay:
|
|
273
|
+
# Ha ha. Someday, maybe.
|
|
274
|
+
options |= UNNotificationCategoryOptionAllowInCarPlay
|
|
275
|
+
if self._hiddenPreviewsShowTitle:
|
|
276
|
+
options |= UNNotificationCategoryOptionHiddenPreviewsShowTitle
|
|
277
|
+
if self._hiddenPreviewsShowSubtitle:
|
|
278
|
+
options |= UNNotificationCategoryOptionHiddenPreviewsShowSubtitle
|
|
279
|
+
return UNNotificationCategory.categoryWithIdentifier_actions_intentIdentifiers_options_(
|
|
280
|
+
self._notificationCategoryID, actions, intentIdentifiers, options
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def _notifyWithTrigger(
|
|
284
|
+
self,
|
|
285
|
+
trigger: UNNotificationTrigger,
|
|
286
|
+
notification: NotifT,
|
|
287
|
+
title: str,
|
|
288
|
+
body: str,
|
|
289
|
+
) -> None:
|
|
290
|
+
notificationID, userInfo = self._tx.toNotification(notification)
|
|
291
|
+
request = UNNotificationRequest.requestWithIdentifier_content_trigger_(
|
|
292
|
+
notificationID,
|
|
293
|
+
make(
|
|
294
|
+
UNMutableNotificationContent,
|
|
295
|
+
title=title,
|
|
296
|
+
body=body,
|
|
297
|
+
categoryIdentifier=self._notificationCategoryID,
|
|
298
|
+
userInfo=userInfo,
|
|
299
|
+
),
|
|
300
|
+
trigger,
|
|
301
|
+
)
|
|
302
|
+
d: Deferred[NSError | None] = Deferred()
|
|
303
|
+
self._cfg._center.addNotificationRequest_withCompletionHandler_(
|
|
304
|
+
request, d.callback
|
|
305
|
+
)
|
|
306
|
+
error = await d
|
|
307
|
+
NSLog("completed notification request with error %@", error)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass
|
|
311
|
+
class _NotifConfigImpl:
|
|
312
|
+
_center: UNUserNotificationCenter
|
|
313
|
+
_notifiers: list[Notifier[Any]]
|
|
314
|
+
_wasGrantedPermission: bool
|
|
315
|
+
_settings: UNNotificationSettings
|
|
316
|
+
|
|
317
|
+
def add[NotifT](
|
|
318
|
+
self,
|
|
319
|
+
category: type[NotifT],
|
|
320
|
+
translator: NotificationTranslator[NotifT],
|
|
321
|
+
allowInCarPlay: bool = False,
|
|
322
|
+
hiddenPreviewsShowTitle: bool = False,
|
|
323
|
+
hiddenPreviewsShowSubtitle: bool = False,
|
|
324
|
+
# customDismissAction: bool = False,
|
|
325
|
+
) -> Notifier[NotifT]:
|
|
326
|
+
"""
|
|
327
|
+
@param category: the category to add
|
|
328
|
+
|
|
329
|
+
@param translator: a translator that can load and save a translator.
|
|
330
|
+
"""
|
|
331
|
+
catid: str = f"{category.__module__}.{category.__qualname__}"
|
|
332
|
+
notifier = Notifier(
|
|
333
|
+
catid,
|
|
334
|
+
self,
|
|
335
|
+
translator,
|
|
336
|
+
_getAllActionInfos(category),
|
|
337
|
+
_allowInCarPlay=allowInCarPlay,
|
|
338
|
+
_hiddenPreviewsShowTitle=hiddenPreviewsShowTitle,
|
|
339
|
+
_hiddenPreviewsShowSubtitle=hiddenPreviewsShowSubtitle,
|
|
340
|
+
)
|
|
341
|
+
self._notifiers.append(notifier)
|
|
342
|
+
return notifier
|
|
343
|
+
|
|
344
|
+
def _notifierByCategory(self, categoryID: str) -> Notifier[Any]:
|
|
345
|
+
for notifier in self._notifiers:
|
|
346
|
+
if categoryID == notifier._notificationCategoryID:
|
|
347
|
+
return notifier
|
|
348
|
+
raise KeyError(categoryID)
|
|
349
|
+
|
|
350
|
+
def _register(self) -> None:
|
|
351
|
+
self._center.setNotificationCategories_(
|
|
352
|
+
[pynot._createUNNotificationCategory() for pynot in self._notifiers]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_ACTION_INFO_ATTR = "__qma_notification_action_info__"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_oneActionInfo = tuple[
|
|
360
|
+
# Action handler to stuff away into dispatch; does the pulling out of
|
|
361
|
+
# userText if necessary
|
|
362
|
+
Callable[[Any, UNNotificationResponse], Awaitable[None]],
|
|
363
|
+
# action ID
|
|
364
|
+
str,
|
|
365
|
+
# the notification action to register; None for default & dismiss
|
|
366
|
+
UNNotificationAction | None,
|
|
367
|
+
UNNotificationCategoryOptions,
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_anyActionInfo: TypeAlias = (
|
|
372
|
+
"_PlainNotificationActionInfo | _TextNotificationActionInfo | _BuiltinActionInfo"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _getActionInfo(o: object) -> _oneActionInfo | None:
|
|
377
|
+
handler: _anyActionInfo | None = getattr(o, _ACTION_INFO_ATTR, None)
|
|
378
|
+
if handler is None:
|
|
379
|
+
return None
|
|
380
|
+
appCallback: Any = o
|
|
381
|
+
actionID = handler.identifier
|
|
382
|
+
callback = handler._makeCallback(appCallback)
|
|
383
|
+
extraOptions = handler._extraOptions
|
|
384
|
+
return (callback, actionID, handler._toAction(), extraOptions)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _setActionInfo[T](wrapt: T, actionInfo: _anyActionInfo) -> T:
|
|
388
|
+
setattr(wrapt, _ACTION_INFO_ATTR, actionInfo)
|
|
389
|
+
return wrapt
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _getAllActionInfos(t: type[object]) -> list[_oneActionInfo]:
|
|
393
|
+
result = []
|
|
394
|
+
for attr in dir(t):
|
|
395
|
+
actionInfo = _getActionInfo(getattr(t, attr, None))
|
|
396
|
+
if actionInfo is not None:
|
|
397
|
+
result.append(actionInfo)
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _py2options(
|
|
402
|
+
foreground: bool,
|
|
403
|
+
destructive: bool,
|
|
404
|
+
authenticationRequired: bool,
|
|
405
|
+
) -> UNNotificationActionOptions:
|
|
406
|
+
"""
|
|
407
|
+
Convert some sensibly-named data types into UNNotificationActionOptions.
|
|
408
|
+
"""
|
|
409
|
+
options = 0
|
|
410
|
+
if foreground:
|
|
411
|
+
options |= UNNotificationActionOptionForeground
|
|
412
|
+
if destructive:
|
|
413
|
+
options |= UNNotificationActionOptionDestructive
|
|
414
|
+
if authenticationRequired:
|
|
415
|
+
options |= UNNotificationActionOptionAuthenticationRequired
|
|
416
|
+
return options
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@dataclass
|
|
420
|
+
class _PlainNotificationActionInfo:
|
|
421
|
+
identifier: str
|
|
422
|
+
title: str
|
|
423
|
+
foreground: bool
|
|
424
|
+
destructive: bool
|
|
425
|
+
authenticationRequired: bool
|
|
426
|
+
_extraOptions: UNNotificationCategoryOptions = 0
|
|
427
|
+
|
|
428
|
+
def _makeCallback[T](
|
|
429
|
+
self, appCallback: Callable[[T], Awaitable[None]]
|
|
430
|
+
) -> Callable[[Any, UNNotificationResponse], Awaitable[None]]:
|
|
431
|
+
async def takesNotification(self: T, response: UNNotificationResponse) -> None:
|
|
432
|
+
await appCallback(self)
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
return takesNotification
|
|
436
|
+
|
|
437
|
+
def _toAction(self) -> UNNotificationAction:
|
|
438
|
+
return UNNotificationAction.actionWithIdentifier_title_options_(
|
|
439
|
+
self.identifier,
|
|
440
|
+
self.title,
|
|
441
|
+
_py2options(
|
|
442
|
+
self.foreground,
|
|
443
|
+
self.destructive,
|
|
444
|
+
self.authenticationRequired,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@dataclass
|
|
450
|
+
class _TextNotificationActionInfo:
|
|
451
|
+
identifier: str
|
|
452
|
+
title: str
|
|
453
|
+
foreground: bool
|
|
454
|
+
destructive: bool
|
|
455
|
+
authenticationRequired: bool
|
|
456
|
+
buttonTitle: str
|
|
457
|
+
textPlaceholder: str
|
|
458
|
+
_extraOptions: UNNotificationCategoryOptions = 0
|
|
459
|
+
|
|
460
|
+
def _makeCallback[T](
|
|
461
|
+
self, appCallback: Callable[[T, str], Awaitable[None]]
|
|
462
|
+
) -> Callable[[Any, UNNotificationResponse], Awaitable[None]]:
|
|
463
|
+
async def takesNotification(self: T, response: UNNotificationResponse) -> None:
|
|
464
|
+
await appCallback(self, response.userText())
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
return takesNotification
|
|
468
|
+
|
|
469
|
+
def _toAction(self) -> UNNotificationAction:
|
|
470
|
+
return UNTextInputNotificationAction.actionWithIdentifier_title_options_textInputButtonTitle_textInputPlaceholder_(
|
|
471
|
+
self.identifier,
|
|
472
|
+
self.title,
|
|
473
|
+
_py2options(
|
|
474
|
+
self.foreground,
|
|
475
|
+
self.destructive,
|
|
476
|
+
self.authenticationRequired,
|
|
477
|
+
),
|
|
478
|
+
self.buttonTitle,
|
|
479
|
+
self.textPlaceholder,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@dataclass
|
|
484
|
+
class _BuiltinActionInfo:
|
|
485
|
+
identifier: str
|
|
486
|
+
_extraOptions: UNNotificationCategoryOptions
|
|
487
|
+
|
|
488
|
+
def _toAction(self) -> None:
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
def _makeCallback[T](
|
|
492
|
+
self, appCallback: Callable[[T], Awaitable[None]]
|
|
493
|
+
) -> Callable[[Any, UNNotificationResponse], Awaitable[None]]:
|
|
494
|
+
async def takesNotification(self: T, response: UNNotificationResponse) -> None:
|
|
495
|
+
await appCallback(self)
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
return takesNotification
|
quickmacapp/_quickapp.py
CHANGED
|
@@ -177,7 +177,7 @@ class QuickApplication(NSApplication):
|
|
|
177
177
|
"""
|
|
178
178
|
QuickMacApp's main application class.
|
|
179
179
|
|
|
180
|
-
@ivar keyEquivalentHandler: Set this attribute to a custom
|
|
180
|
+
@ivar keyEquivalentHandler: Set this attribute to a custom C{NSResponder}
|
|
181
181
|
if you want to handle key equivalents outside the responder chain. (I
|
|
182
182
|
believe this is necessary in some apps because the responder chain can
|
|
183
183
|
be more complicated in LSUIElement apps, but there might be a better
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API for emitting macOS notifications.
|
|
3
|
+
|
|
4
|
+
@see: L{configureNotifications}.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextlib import AbstractAsyncContextManager as _AbstractAsyncContextManager
|
|
8
|
+
from dataclasses import dataclass as _dataclass
|
|
9
|
+
from typing import Callable, Protocol
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from datetype import DateTime
|
|
13
|
+
from UserNotifications import (
|
|
14
|
+
UNNotificationCategoryOptionCustomDismissAction as _UNNotificationCategoryOptionCustomDismissAction,
|
|
15
|
+
UNNotificationDefaultActionIdentifier as _UNNotificationDefaultActionIdentifier,
|
|
16
|
+
UNNotificationDismissActionIdentifier as _UNNotificationDismissActionIdentifier,
|
|
17
|
+
)
|
|
18
|
+
from UserNotifications import UNUserNotificationCenter as _UNUserNotificationCenter
|
|
19
|
+
|
|
20
|
+
from quickmacapp._notifications import (
|
|
21
|
+
NotificationTranslator,
|
|
22
|
+
_AppNotificationsCtxBuilder,
|
|
23
|
+
_BuiltinActionInfo,
|
|
24
|
+
_PlainNotificationActionInfo,
|
|
25
|
+
_setActionInfo,
|
|
26
|
+
_TextNotificationActionInfo,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"NotificationTranslator",
|
|
31
|
+
"Action",
|
|
32
|
+
"TextAction",
|
|
33
|
+
"response",
|
|
34
|
+
"Notifier",
|
|
35
|
+
"NotificationConfig",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Action[NotificationT](Protocol):
|
|
40
|
+
"""
|
|
41
|
+
An action is just an async method that takes its C{self} (an instance of a
|
|
42
|
+
notification class encapsulating the ID & data), and reacts to the
|
|
43
|
+
specified action.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
async def __call__(__no_self__, /, self: NotificationT) -> None:
|
|
47
|
+
"""
|
|
48
|
+
React to the action.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TextAction[NotificationT](Protocol):
|
|
53
|
+
"""
|
|
54
|
+
A L{TextAction} is just like an L{Action}, but it takes some text.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
async def __call__(__no_self__, /, self: NotificationT, text: str) -> None:
|
|
58
|
+
"""
|
|
59
|
+
React to the action with the user's input.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@_dataclass
|
|
64
|
+
class response:
|
|
65
|
+
identifier: str
|
|
66
|
+
title: str
|
|
67
|
+
foreground: bool = False
|
|
68
|
+
destructive: bool = False
|
|
69
|
+
authenticationRequired: bool = False
|
|
70
|
+
|
|
71
|
+
def __call__[NT](self, action: Action[NT], /) -> Action[NT]:
|
|
72
|
+
return _setActionInfo(
|
|
73
|
+
action,
|
|
74
|
+
_PlainNotificationActionInfo(
|
|
75
|
+
identifier=self.identifier,
|
|
76
|
+
title=self.title,
|
|
77
|
+
foreground=self.foreground,
|
|
78
|
+
destructive=self.destructive,
|
|
79
|
+
authenticationRequired=self.authenticationRequired,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def text[NT](
|
|
84
|
+
self, *, title: str | None = None, placeholder: str = ""
|
|
85
|
+
) -> Callable[[TextAction[NT]], TextAction[NT]]:
|
|
86
|
+
return lambda wrapt: _setActionInfo(
|
|
87
|
+
wrapt,
|
|
88
|
+
_TextNotificationActionInfo(
|
|
89
|
+
identifier=self.identifier,
|
|
90
|
+
title=self.title,
|
|
91
|
+
buttonTitle=title if title is not None else self.title,
|
|
92
|
+
textPlaceholder=placeholder,
|
|
93
|
+
foreground=self.foreground,
|
|
94
|
+
destructive=self.destructive,
|
|
95
|
+
authenticationRequired=self.authenticationRequired,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def default[NT]() -> Callable[[Action[NT]], Action[NT]]:
|
|
101
|
+
return lambda wrapt: _setActionInfo(
|
|
102
|
+
wrapt, _BuiltinActionInfo(_UNNotificationDefaultActionIdentifier, 0)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def dismiss[NT]() -> Callable[[Action[NT]], Action[NT]]:
|
|
107
|
+
return lambda wrapt: _setActionInfo(
|
|
108
|
+
wrapt,
|
|
109
|
+
_BuiltinActionInfo(
|
|
110
|
+
_UNNotificationDismissActionIdentifier,
|
|
111
|
+
_UNNotificationCategoryOptionCustomDismissAction,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Notifier[NotifT](Protocol):
|
|
117
|
+
"""
|
|
118
|
+
A L{Notifier} can deliver notifications.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
async def notifyAt(
|
|
122
|
+
self, when: DateTime[ZoneInfo], notification: NotifT, title: str, body: str
|
|
123
|
+
) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Request a future notification at the given time.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def undeliver(self, notification: NotifT) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Remove the previously-delivered notification object from the
|
|
131
|
+
notification center, if it's still there.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def unsend(self, notification: NotifT) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Prevent the as-yet undelivered notification object from being
|
|
137
|
+
delivered.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class NotificationConfig(Protocol):
|
|
142
|
+
"""
|
|
143
|
+
The application-wide configuration for a notification.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def add[NotifT](
|
|
147
|
+
self,
|
|
148
|
+
category: type[NotifT],
|
|
149
|
+
translator: NotificationTranslator[NotifT],
|
|
150
|
+
allowInCarPlay: bool = False,
|
|
151
|
+
hiddenPreviewsShowTitle: bool = False,
|
|
152
|
+
hiddenPreviewsShowSubtitle: bool = False,
|
|
153
|
+
) -> Notifier[NotifT]:
|
|
154
|
+
"""
|
|
155
|
+
Add a new category (represented by a plain Python class, which may have
|
|
156
|
+
some methods that were decorated with L{response}C{(...)},
|
|
157
|
+
L{response.text}C{(...)}).
|
|
158
|
+
|
|
159
|
+
@param category: A Python type that contains the internal state for the
|
|
160
|
+
notifications that will be emitted, that will be relayed back to
|
|
161
|
+
its responses (e.g. the response methods on the category).
|
|
162
|
+
|
|
163
|
+
@param translator: a translator that can serialize and deserialize
|
|
164
|
+
python objects to C{UNUserNotificationCenter} values.
|
|
165
|
+
|
|
166
|
+
@param allowInCarPlay: Should the notification be allowed to show in
|
|
167
|
+
CarPlay?
|
|
168
|
+
|
|
169
|
+
@param hiddenPreviewsShowTitle: Should lock-screen previews for this
|
|
170
|
+
notification show the unredacted title?
|
|
171
|
+
|
|
172
|
+
@param hiddenPreviewsShowSubtitle: Should lock-screen previews for this
|
|
173
|
+
notification show the unredacted subtitle?
|
|
174
|
+
|
|
175
|
+
@return: A L{Notifier} that can deliver notifications to macOS.
|
|
176
|
+
|
|
177
|
+
@note: This method can I{only} be called within the C{with} statement
|
|
178
|
+
for the context manager beneath C{configureNotifications}, and can
|
|
179
|
+
only do this once per process. Otherwise it will raise an
|
|
180
|
+
exception.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def configureNotifications() -> _AbstractAsyncContextManager[NotificationConfig]:
|
|
185
|
+
"""
|
|
186
|
+
Configure notifications for the current application.
|
|
187
|
+
|
|
188
|
+
This is an asynchronous (using Twisted's Deferred) context manager, run
|
|
189
|
+
with `with` statement, which works like this::
|
|
190
|
+
|
|
191
|
+
async with configureNotifications() as cfg:
|
|
192
|
+
notifier = cfg.add(MyNotificationData, MyNotificationLoader())
|
|
193
|
+
|
|
194
|
+
Each L{add <NotificationConfig.add>} invocation adds a category of
|
|
195
|
+
notifications you can send, and returns an object (a L{Notifier}) that can
|
|
196
|
+
send that category of notification.
|
|
197
|
+
|
|
198
|
+
At the end of the C{async with} block, the notification configuration is
|
|
199
|
+
finalized, its state is sent to macOS, and the categories of notification
|
|
200
|
+
your application can send is frozen for the rest of the lifetime of your
|
|
201
|
+
process; the L{Notifier} objects returned from L{add
|
|
202
|
+
<NotificationConfig.add>} are now active nad can be used. Note that you
|
|
203
|
+
may only call L{configureNotifications} once in your entire process, so you
|
|
204
|
+
will need to pass those notifiers elsewhere!
|
|
205
|
+
|
|
206
|
+
Each call to add requires 2 arguments: a notification-data class which
|
|
207
|
+
stores the sent notification's ID and any other ancillary data transmitted
|
|
208
|
+
along with it, and an object that can load and store that first class, when
|
|
209
|
+
notification responses from the operating system convey data that was
|
|
210
|
+
previously scheduled as a notification. In our example above, they can be
|
|
211
|
+
as simple as this::
|
|
212
|
+
|
|
213
|
+
class MyNotificationData:
|
|
214
|
+
id: str
|
|
215
|
+
|
|
216
|
+
class MyNotificationLoader:
|
|
217
|
+
def fromNotification(
|
|
218
|
+
self, notificationID: str, userData: dict[str, object]
|
|
219
|
+
) -> MyNotificationData:
|
|
220
|
+
return MyNotificationData(notificationID)
|
|
221
|
+
def toNotification(
|
|
222
|
+
self,
|
|
223
|
+
notification: MyNotificationData,
|
|
224
|
+
) -> tuple[str, dict[str, object]]:
|
|
225
|
+
return (notification.id, {})
|
|
226
|
+
|
|
227
|
+
Then, when you want to I{send} a notification, you can do::
|
|
228
|
+
|
|
229
|
+
await notifier.notifyAt(
|
|
230
|
+
aware(datetime.now(TZ) + timedelta(seconds=5), TZ),
|
|
231
|
+
MyNotificationData("my.notification.id.1"),
|
|
232
|
+
"Title Here",
|
|
233
|
+
"Subtitle Here",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
And that will show the user a notification.
|
|
237
|
+
|
|
238
|
+
The C{MyNotificationData} class might seem simplistic to the point of
|
|
239
|
+
uselessness, and in this oversimplified case, it is! However, if you are
|
|
240
|
+
sending notifications to a user, you really need to be able to I{respond}
|
|
241
|
+
to notifications from a user, and that's where your notification data class
|
|
242
|
+
as well as L{response} comes in. To respond to a notification when the
|
|
243
|
+
user clicks on it, you can add a method like so::
|
|
244
|
+
|
|
245
|
+
class MyNotificationData:
|
|
246
|
+
id: str
|
|
247
|
+
|
|
248
|
+
@response(identifier="response-action-1", title="Action 1")
|
|
249
|
+
async def responseAction1(self) -> None:
|
|
250
|
+
await answer("User pressed 'Action 1' button")
|
|
251
|
+
|
|
252
|
+
@response.default()
|
|
253
|
+
async def userClicked(self) -> None:
|
|
254
|
+
await answer("User clicked the notification.")
|
|
255
|
+
|
|
256
|
+
When sent with L{Notifier.notifyAt}, your C{MyNotificationData} class will
|
|
257
|
+
be serialized and deserialized with C{MyNotificationLoader.toNotification}
|
|
258
|
+
(converting your Python class into a macOS notification, to send along to
|
|
259
|
+
the OS) and C{MyNotificationLoader.fromNotification} (converting the data
|
|
260
|
+
sent along with the user's response back into a C{MyNotificationData}).
|
|
261
|
+
|
|
262
|
+
@note: If your app schedules a notification, then quits, when the user
|
|
263
|
+
responds (clicks on it, uses a button, dismisses it, etc) then the OS
|
|
264
|
+
will re-launch your application and send the notification data back in,
|
|
265
|
+
which is why all the serialization and deserialization is required.
|
|
266
|
+
Your process may have exited and thus the original notification will no
|
|
267
|
+
longer be around. However, if you are just running as a Python script,
|
|
268
|
+
piggybacking on the 'Python Launcher' app bundle, macOS will not be
|
|
269
|
+
able to re-launch your app. Notifications going back to the same
|
|
270
|
+
process seem to work okay, but note that as documented, macOS really
|
|
271
|
+
requires your application to have its own bundle and its own unique
|
|
272
|
+
CFBundleIdentifier in order to avoid any weird behavior.
|
|
273
|
+
"""
|
|
274
|
+
return _AppNotificationsCtxBuilder(
|
|
275
|
+
_UNUserNotificationCenter.currentNotificationCenter(), None
|
|
276
|
+
)
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: quickmacapp
|
|
3
|
-
Version: 2025.
|
|
3
|
+
Version: 2025.4.15
|
|
4
4
|
Summary: Make it easier to write Mac apps in Python
|
|
5
5
|
Description-Content-Type: text/x-rst
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Dist: pyobjc-framework-Cocoa
|
|
8
8
|
Requires-Dist: pyobjc-framework-ExceptionHandling
|
|
9
9
|
Requires-Dist: pyobjc-framework-UserNotifications
|
|
10
|
+
Requires-Dist: datetype
|
|
10
11
|
Requires-Dist: twisted[macos_platform,tls]
|
|
12
|
+
Dynamic: license-file
|
|
11
13
|
|
|
12
14
|
QuickMacApp
|
|
13
15
|
==============================
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
quickmacapp/__init__.py,sha256=NsZW_cPvJTunDShN3RjOyK1gTwTKsghMb7wb6vRgTXI,335
|
|
2
|
+
quickmacapp/_background.py,sha256=M_ob3EF8v-Z_gpKuFDl2N9XrQz0eBLFfUp2XErDB3DU,5943
|
|
3
|
+
quickmacapp/_interactions.py,sha256=eLv_mVf5Jr-puNizlR8tDL_DlLy1Rc82XQ80o0GP5R4,3439
|
|
4
|
+
quickmacapp/_notifications.py,sha256=DNm4-vOF0wQNUUTBHqp3-RKXVQK98ef53isxwsWqyj8,16965
|
|
5
|
+
quickmacapp/_quickapp.py,sha256=Ed5n9rAw12nlHxyBMzyPc1Vt4K2OVIqCszWPqQyVElk,7571
|
|
6
|
+
quickmacapp/notifications.py,sha256=UCigVIurf-zWAqVXUYjTtz_xMjcoI23dqyJ4OzZurQM,10275
|
|
7
|
+
quickmacapp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
quickmacapp-2025.4.15.dist-info/licenses/LICENSE,sha256=7RIBNNvrnKHR3lw9z3KXv3Q6RRjhyxtNQoMnoUsf3_M,1091
|
|
9
|
+
quickmacapp-2025.4.15.dist-info/METADATA,sha256=KV6153Iy7rHC5Xrgs8RVtTtBauSRG9GNKzOk3btM-Rg,1344
|
|
10
|
+
quickmacapp-2025.4.15.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
11
|
+
quickmacapp-2025.4.15.dist-info/top_level.txt,sha256=_iJkekUYnuWhCZbFSQyo2d5_6B7OoPwx7k527bokzeA,12
|
|
12
|
+
quickmacapp-2025.4.15.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
quickmacapp/__init__.py,sha256=NsZW_cPvJTunDShN3RjOyK1gTwTKsghMb7wb6vRgTXI,335
|
|
2
|
-
quickmacapp/_background.py,sha256=M_ob3EF8v-Z_gpKuFDl2N9XrQz0eBLFfUp2XErDB3DU,5943
|
|
3
|
-
quickmacapp/_interactions.py,sha256=eLv_mVf5Jr-puNizlR8tDL_DlLy1Rc82XQ80o0GP5R4,3439
|
|
4
|
-
quickmacapp/_quickapp.py,sha256=UdZQKIJG40OSzzJ4bdMWix7bpgyZnDAGftF-ksk1VDw,7571
|
|
5
|
-
quickmacapp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
quickmacapp-2025.3.18.dist-info/LICENSE,sha256=7RIBNNvrnKHR3lw9z3KXv3Q6RRjhyxtNQoMnoUsf3_M,1091
|
|
7
|
-
quickmacapp-2025.3.18.dist-info/METADATA,sha256=7db1LqTDV1pWVXzIEjjubLE7t4_XU4nW4mTxMhMklxo,1298
|
|
8
|
-
quickmacapp-2025.3.18.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
|
|
9
|
-
quickmacapp-2025.3.18.dist-info/top_level.txt,sha256=_iJkekUYnuWhCZbFSQyo2d5_6B7OoPwx7k527bokzeA,12
|
|
10
|
-
quickmacapp-2025.3.18.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|