quickmacapp 2025.3.16__tar.gz → 2025.4.4__tar.gz

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.
@@ -1,13 +1,15 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: quickmacapp
3
- Version: 2025.3.16
3
+ Version: 2025.4.4
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
  ==============================
@@ -9,10 +9,11 @@ build-backend = "setuptools.build_meta"
9
9
  name = "quickmacapp"
10
10
  description = "Make it easier to write Mac apps in Python"
11
11
  readme = "README.rst"
12
- version = "2025.03.16"
12
+ version = "2025.04.04"
13
13
  dependencies = [
14
14
  "pyobjc-framework-Cocoa",
15
15
  "pyobjc-framework-ExceptionHandling",
16
16
  "pyobjc-framework-UserNotifications",
17
+ "datetype",
17
18
  "twisted[tls,macos_platform]",
18
19
  ]
@@ -1,5 +1,6 @@
1
1
  from ._quickapp import Actionable, Status, mainpoint, menu, quit
2
2
  from ._interactions import ask, choose, answer, getpass
3
+ from ._background import dockIconWhenVisible
3
4
 
4
5
  __all__ = [
5
6
  "Actionable",
@@ -11,4 +12,5 @@ __all__ = [
11
12
  "choose",
12
13
  "answer",
13
14
  "getpass",
15
+ "dockIconWhenVisible",
14
16
  ]
@@ -0,0 +1,155 @@
1
+ from typing import Any, Callable
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from AppKit import (
6
+ NSApplication,
7
+ NSApplicationActivateIgnoringOtherApps,
8
+ NSApplicationActivationPolicyAccessory,
9
+ NSApplicationActivationPolicyRegular,
10
+ NSLog,
11
+ NSNotification,
12
+ NSNotificationCenter,
13
+ NSRunningApplication,
14
+ NSWindow,
15
+ NSWindowWillCloseNotification,
16
+ NSWorkspace,
17
+ NSWorkspaceActiveSpaceDidChangeNotification,
18
+ NSWorkspaceApplicationKey,
19
+ NSWorkspaceDidActivateApplicationNotification,
20
+ NSWorkspaceDidHideApplicationNotification,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class SometimesBackground:
26
+ """
27
+ An application that is sometimes in the background but has a window that,
28
+ when visible, can own the menubar, become key, etc. However, when that
29
+ window is closed, we withdraw to the menu bar and continue running in the
30
+ background, as an accessory.
31
+ """
32
+
33
+ mainWindow: NSWindow
34
+ hideIconOnOtherSpaces: bool
35
+ onSpaceChange: Callable[[], None]
36
+ currentlyRegular: bool = False
37
+ previouslyActiveApp: NSRunningApplication = field(init=False)
38
+
39
+ def someApplicationActivated_(self, notification: Any) -> None:
40
+ # NSLog(f"active {notification} {__file__}")
41
+ whichApp = notification.userInfo()[NSWorkspaceApplicationKey]
42
+
43
+ if whichApp == NSRunningApplication.currentApplication():
44
+ if self.currentlyRegular:
45
+ # NSLog("show editor window")
46
+ self.mainWindow.setIsVisible_(True)
47
+ else:
48
+ # NSLog("reactivate workaround")
49
+ self.currentlyRegular = True
50
+ self.previouslyActiveApp.activateWithOptions_(
51
+ NSApplicationActivateIgnoringOtherApps
52
+ )
53
+ app = NSApplication.sharedApplication()
54
+ app.setActivationPolicy_(NSApplicationActivationPolicyRegular)
55
+ self.mainWindow.setIsVisible_(True)
56
+ from twisted.internet import reactor
57
+
58
+ reactor.callLater( # type:ignore[attr-defined]
59
+ 0.1, lambda: app.activateIgnoringOtherApps_(True)
60
+ )
61
+ else:
62
+ self.previouslyActiveApp = whichApp
63
+
64
+ def someApplicationHidden_(self, notification: Any) -> None:
65
+ """
66
+ An app was hidden.
67
+ """
68
+ whichApp = notification.userInfo()[NSWorkspaceApplicationKey]
69
+ if whichApp == NSRunningApplication.currentApplication():
70
+ # 'hide others' (and similar functionality) should *not* hide the
71
+ # progress window; that would obviate the whole point of having
72
+ # this app live in the background in order to maintain a constant
73
+ # presence in the user's visual field. however if we're being told
74
+ # to hide, don't ignore the user, hide the main window and retreat
75
+ # into the background as if we were closed.
76
+ self.mainWindow.close()
77
+ app = NSApplication.sharedApplication()
78
+ app.unhide_(self)
79
+
80
+ def someSpaceActivated_(self, notification: NSNotification) -> None:
81
+ """
82
+ Sometimes, fullscreen application stop getting the HUD overlay.
83
+ """
84
+ menuBarOwner = NSWorkspace.sharedWorkspace().menuBarOwningApplication()
85
+ # me = NSRunningApplication.currentApplication()
86
+ NSLog("space activated where allegedly %@ owns the menu bar", menuBarOwner)
87
+ if not self.mainWindow.isOnActiveSpace():
88
+ if self.hideIconOnOtherSpaces:
89
+ NSLog("I am not on the active space, closing the window")
90
+ self.mainWindow.close()
91
+ else:
92
+ NSLog("I am not on the active space, but that's OK, leaving window open.")
93
+ else:
94
+ NSLog("I am on the active space; not closing.")
95
+ self.onSpaceChange()
96
+
97
+ def someWindowWillClose_(self, notification: NSNotification) -> None:
98
+ """
99
+ The main window that we're observing will close.
100
+ """
101
+ if notification.object() == self.mainWindow:
102
+ self.currentlyRegular = False
103
+ NSApplication.sharedApplication().setActivationPolicy_(
104
+ NSApplicationActivationPolicyAccessory
105
+ )
106
+
107
+ def startObserving(self) -> None:
108
+ """
109
+ Attach the various callbacks.
110
+ """
111
+ NSNotificationCenter.defaultCenter().addObserver_selector_name_object_(
112
+ self, "someWindowWillClose:", NSWindowWillCloseNotification, None
113
+ )
114
+ wsnc = NSWorkspace.sharedWorkspace().notificationCenter()
115
+
116
+ self.previouslyActiveApp = (
117
+ NSWorkspace.sharedWorkspace().menuBarOwningApplication()
118
+ )
119
+
120
+ wsnc.addObserver_selector_name_object_(
121
+ self,
122
+ "someApplicationActivated:",
123
+ NSWorkspaceDidActivateApplicationNotification,
124
+ None,
125
+ )
126
+
127
+ wsnc.addObserver_selector_name_object_(
128
+ self,
129
+ "someApplicationHidden:",
130
+ NSWorkspaceDidHideApplicationNotification,
131
+ None,
132
+ )
133
+
134
+ wsnc.addObserver_selector_name_object_(
135
+ self,
136
+ "someSpaceActivated:",
137
+ NSWorkspaceActiveSpaceDidChangeNotification,
138
+ None,
139
+ )
140
+
141
+
142
+ def dockIconWhenVisible(
143
+ mainWindow: NSWindow,
144
+ hideIconOnOtherSpaces: bool = True,
145
+ onSpaceChange: Callable[[], None] = lambda: None,
146
+ ):
147
+ """
148
+ When the given main window is visible, we should have a dock icon (i.e.: be
149
+ NSApplicationActivationPolicyRegular). When our application is activated,
150
+ (i.e.: the user launches it from Spotlight, Finder, or similar) we should
151
+ make the window visible so that the dock icon appears. When that window is
152
+ then closed, or when our application is hidden, we should hide our dock
153
+ icon (i.e.: be NSApplicationActivationPolicyAccessory).
154
+ """
155
+ SometimesBackground(mainWindow, hideIconOnOtherSpaces, onSpaceChange).startObserving()
@@ -0,0 +1,479 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from zoneinfo import ZoneInfo
5
+ from types import TracebackType
6
+ from typing import Any, Awaitable, Callable, Protocol, TypeAlias
7
+
8
+ from datetype import DateTime
9
+
10
+ from Foundation import NSError, NSLog, NSObject, NSDateComponents
11
+ from objc import object_property
12
+ from twisted.internet.defer import Deferred
13
+ from UserNotifications import (
14
+ UNNotificationCategoryOptions,
15
+ UNAuthorizationOptionNone,
16
+ UNMutableNotificationContent,
17
+ UNNotification,
18
+ UNNotificationAction,
19
+ UNNotificationActionOptions,
20
+ UNNotificationActionOptionAuthenticationRequired,
21
+ UNNotificationActionOptionDestructive,
22
+ UNNotificationActionOptionForeground,
23
+ UNNotificationCategory,
24
+ UNNotificationCategoryOptionAllowInCarPlay,
25
+ UNNotificationCategoryOptionHiddenPreviewsShowSubtitle,
26
+ UNNotificationCategoryOptionHiddenPreviewsShowTitle,
27
+ UNNotificationPresentationOptionBanner,
28
+ UNNotificationPresentationOptions,
29
+ UNNotificationRequest,
30
+ UNNotificationResponse,
31
+ UNNotificationSettings,
32
+ UNNotificationTrigger,
33
+ UNCalendarNotificationTrigger,
34
+ UNTextInputNotificationAction,
35
+ UNUserNotificationCenter,
36
+ )
37
+
38
+
39
+ def make[T: NSObject](cls: type[T], **attributes: object) -> T:
40
+ self: T = cls.alloc().init()
41
+ self.setValuesForKeysWithDictionary_(attributes)
42
+ return self
43
+
44
+
45
+ @dataclass
46
+ class _AppNotificationsCtxBuilder:
47
+ _center: UNUserNotificationCenter
48
+ _cfg: _NotifConfigImpl | None
49
+
50
+ async def __aenter__(self) -> _NotifConfigImpl:
51
+ """
52
+ Request authorization, then start building this notifications manager.
53
+ """
54
+ NSLog("beginning build")
55
+ grantDeferred: Deferred[bool] = Deferred()
56
+
57
+ def completed(granted: bool, error: NSError | None) -> None:
58
+ # TODO: convert non-None NSErrors into failures on this Deferred
59
+ grantDeferred.callback(granted)
60
+ NSLog(
61
+ "Notification authorization response: %@ with error: %@", granted, error
62
+ )
63
+
64
+ NSLog("requesting authorization")
65
+ self._center.requestAuthorizationWithOptions_completionHandler_(
66
+ UNAuthorizationOptionNone, completed
67
+ )
68
+ NSLog("requested")
69
+ granted = await grantDeferred
70
+ settingsDeferred: Deferred[UNNotificationSettings] = Deferred()
71
+
72
+ def gotSettings(settings: UNNotificationSettings) -> None:
73
+ NSLog("received notification settings %@", settings)
74
+ settingsDeferred.callback(settings)
75
+
76
+ NSLog("requesting notification settings")
77
+ self._center.getNotificationSettingsWithCompletionHandler_(gotSettings)
78
+ settings = await settingsDeferred
79
+ NSLog("initializing config")
80
+ self.cfg = _NotifConfigImpl(
81
+ self._center,
82
+ [],
83
+ _wasGrantedPermission=granted,
84
+ _settings=settings,
85
+ )
86
+ NSLog("done!")
87
+ return self.cfg
88
+
89
+ async def __aexit__(
90
+ self,
91
+ exc_type: type[BaseException] | None,
92
+ exc_value: BaseException | None,
93
+ traceback: TracebackType | None,
94
+ /,
95
+ ) -> bool:
96
+ """
97
+ Finalize the set of notification categories and actions in use for this application.
98
+ """
99
+ NSLog("async exit from ctx manager")
100
+ if traceback is None and self.cfg is not None:
101
+ qmandw = _QMANotificationDelegateWrapper.alloc().initWithConfig_(self.cfg)
102
+ qmandw.retain()
103
+ NSLog("Setting delegate! %@", qmandw)
104
+ self._center.setDelegate_(qmandw)
105
+ self.cfg._register()
106
+ else:
107
+ NSLog("NOT setting delegate!!!")
108
+ return False
109
+
110
+
111
+ class _QMANotificationDelegateWrapper(NSObject):
112
+ """
113
+ UNUserNotificationCenterDelegate implementation.
114
+ """
115
+
116
+ config: _NotifConfigImpl = object_property()
117
+
118
+ def initWithConfig_(self, cfg: _NotifConfigImpl) -> _QMANotificationDelegateWrapper:
119
+ self.config = cfg
120
+ return self
121
+
122
+ def userNotificationCenter_willPresentNotification_withCompletionHandler_(
123
+ self,
124
+ notificationCenter: UNUserNotificationCenter,
125
+ notification: UNNotification,
126
+ completionHandler: Callable[[UNNotificationPresentationOptions], None],
127
+ ) -> None:
128
+ NSLog("willPresent: %@", notification)
129
+ # TODO: allow for client code to customize this; here we are saying
130
+ # "please present the notification to the user as a banner, even if we
131
+ # are in the foreground". We should allow for customization on a
132
+ # category basis; rather than @response.something, maybe
133
+ # @present.something, as a method on the python category class?
134
+ completionHandler(UNNotificationPresentationOptionBanner)
135
+
136
+ def userNotificationCenter_didReceiveNotificationResponse_withCompletionHandler_(
137
+ self,
138
+ notificationCenter: UNUserNotificationCenter,
139
+ notificationResponse: UNNotificationResponse,
140
+ completionHandler: Callable[[], None],
141
+ ) -> None:
142
+ """
143
+ We received a response to a notification.
144
+ """
145
+ NSLog("received notification repsonse %@", notificationResponse)
146
+
147
+ # TODO: actually hook up the dispatch of the notification response to
148
+ # the registry of action callbacks already set up in
149
+ # NotificationConfig.
150
+ async def respond() -> None:
151
+ notifier = self.config._notifierByCategory(
152
+ notificationResponse.notification()
153
+ .request()
154
+ .content()
155
+ .categoryIdentifier()
156
+ )
157
+ await notifier._handleResponse(notificationResponse)
158
+ completionHandler()
159
+
160
+ Deferred.fromCoroutine(respond()).addErrback(
161
+ lambda error: NSLog("error: %@", error)
162
+ )
163
+
164
+
165
+ class NotificationTranslator[T](Protocol):
166
+ """
167
+ Translate notifications from the notification ID and some user data,
168
+ """
169
+
170
+ def fromNotification(self, notificationID: str, userData: dict[str, Any]) -> T:
171
+ """
172
+ A user interacted with a notification with the given parameters;
173
+ deserialize them into a Python object that can process that action.
174
+ """
175
+
176
+ def toNotification(self, notification: T) -> tuple[str, dict[str, Any]]:
177
+ """
178
+ The application has requested to send a notification to the operating
179
+ system, serialize the Python object represneting this category of
180
+ notification into a 2-tuple of C{notificatcionID}, C{userData} that can
181
+ be encapsulated in a L{UNNotificationRequest}.
182
+ """
183
+
184
+
185
+ @dataclass
186
+ class Notifier[NotifT]:
187
+ """
188
+ A notifier for a specific category.
189
+ """
190
+
191
+ # Public interface:
192
+ def undeliver(self, notification: NotifT) -> None:
193
+ """
194
+ Remove the previously-delivered notification object from the
195
+ notification center, if it's still there.
196
+ """
197
+ notID, _ = self._tx.toNotification(notification)
198
+ self._cfg._center.removeDeliveredNotificationsWithIdentifiers_([notID])
199
+
200
+ def unsend(self, notification: NotifT) -> None:
201
+ """
202
+ Prevent the as-yet undelivered notification object from being
203
+ delivered.
204
+ """
205
+ notID, _ = self._tx.toNotification(notification)
206
+ self._cfg._center.removePendingNotificationRequestsWithIdentifiers_([notID])
207
+
208
+ async def notifyAt(
209
+ self, when: DateTime[ZoneInfo], notification: NotifT, title: str, body: str
210
+ ) -> None:
211
+ components: NSDateComponents = NSDateComponents.alloc().init()
212
+ repeats: bool = False
213
+ trigger: UNNotificationTrigger = (
214
+ UNCalendarNotificationTrigger.triggerWithDateMatchingComponents_repeats_(
215
+ components,
216
+ repeats,
217
+ )
218
+ )
219
+ await self._notifyWithTrigger(trigger, notification, title, body)
220
+
221
+ # Attributes:
222
+ _notificationCategoryID: str
223
+ _cfg: _NotifConfigImpl
224
+ _tx: NotificationTranslator[NotifT]
225
+ _actionInfos: list[_oneActionInfo]
226
+ _allowInCarPlay: bool
227
+ _hiddenPreviewsShowTitle: bool
228
+ _hiddenPreviewsShowSubtitle: bool
229
+
230
+ # Private implementation details:
231
+ async def _handleResponse(self, response: UNNotificationResponse) -> None:
232
+ userInfo = response.notification().request().content().userInfo()
233
+ actionID: str = response.actionIdentifier()
234
+ notificationID: str = response.notification().request().identifier()
235
+ cat = self._tx.fromNotification(notificationID, userInfo)
236
+ for cb, eachActionID, action, options in self._actionInfos:
237
+ if actionID == eachActionID:
238
+ break
239
+ else:
240
+ raise KeyError(actionID)
241
+ await cb(cat, response)
242
+
243
+ def _createUNNotificationCategory(self) -> UNNotificationCategory:
244
+ actions = []
245
+ # We don't yet support intent identifiers.
246
+ intentIdentifiers: list[str] = []
247
+ options = 0
248
+ for handler, actionID, toRegister, extraOptions in self._actionInfos:
249
+ options |= extraOptions
250
+ if toRegister is not None:
251
+ actions.append(toRegister)
252
+ NSLog("actions generated: %@ options: %@", actions, options)
253
+ if self._allowInCarPlay:
254
+ # Ha ha. Someday, maybe.
255
+ options |= UNNotificationCategoryOptionAllowInCarPlay
256
+ if self._hiddenPreviewsShowTitle:
257
+ options |= UNNotificationCategoryOptionHiddenPreviewsShowTitle
258
+ if self._hiddenPreviewsShowSubtitle:
259
+ options |= UNNotificationCategoryOptionHiddenPreviewsShowSubtitle
260
+ return UNNotificationCategory.categoryWithIdentifier_actions_intentIdentifiers_options_(
261
+ self._notificationCategoryID, actions, intentIdentifiers, options
262
+ )
263
+
264
+ async def _notifyWithTrigger(
265
+ self,
266
+ trigger: UNNotificationTrigger,
267
+ notification: NotifT,
268
+ title: str,
269
+ body: str,
270
+ ) -> None:
271
+ notificationID, userInfo = self._tx.toNotification(notification)
272
+ request = UNNotificationRequest.requestWithIdentifier_content_trigger_(
273
+ notificationID,
274
+ make(
275
+ UNMutableNotificationContent,
276
+ title=title,
277
+ body=body,
278
+ categoryIdentifier=self._notificationCategoryID,
279
+ userInfo=userInfo,
280
+ ),
281
+ trigger,
282
+ )
283
+ d: Deferred[NSError | None] = Deferred()
284
+ self._cfg._center.addNotificationRequest_withCompletionHandler_(
285
+ request, d.callback
286
+ )
287
+ error = await d
288
+ NSLog("completed notification request with error %@", error)
289
+
290
+
291
+ @dataclass
292
+ class _NotifConfigImpl:
293
+ _center: UNUserNotificationCenter
294
+ _notifiers: list[Notifier[Any]]
295
+ _wasGrantedPermission: bool
296
+ _settings: UNNotificationSettings
297
+
298
+ def add[NotifT](
299
+ self,
300
+ category: type[NotifT],
301
+ translator: NotificationTranslator[NotifT],
302
+ allowInCarPlay: bool = False,
303
+ hiddenPreviewsShowTitle: bool = False,
304
+ hiddenPreviewsShowSubtitle: bool = False,
305
+ # customDismissAction: bool = False,
306
+ ) -> Notifier[NotifT]:
307
+ """
308
+ @param category: the category to add
309
+
310
+ @param translator: a translator that can load and save a translator.
311
+ """
312
+ catid: str = f"{category.__module__}.{category.__qualname__}"
313
+ notifier = Notifier(
314
+ catid,
315
+ self,
316
+ translator,
317
+ _getAllActionInfos(category),
318
+ _allowInCarPlay=allowInCarPlay,
319
+ _hiddenPreviewsShowTitle=hiddenPreviewsShowTitle,
320
+ _hiddenPreviewsShowSubtitle=hiddenPreviewsShowSubtitle,
321
+ )
322
+ self._notifiers.append(notifier)
323
+ return notifier
324
+
325
+ def _notifierByCategory(self, categoryID: str) -> Notifier[Any]:
326
+ for notifier in self._notifiers:
327
+ if categoryID == notifier._notificationCategoryID:
328
+ return notifier
329
+ raise KeyError(categoryID)
330
+
331
+ def _register(self) -> None:
332
+ self._center.setNotificationCategories_(
333
+ [pynot._createUNNotificationCategory() for pynot in self._notifiers]
334
+ )
335
+
336
+
337
+ _ACTION_INFO_ATTR = "__qma_notification_action_info__"
338
+
339
+
340
+ _oneActionInfo = tuple[
341
+ # Action handler to stuff away into dispatch; does the pulling out of
342
+ # userText if necessary
343
+ Callable[[Any, UNNotificationResponse], Awaitable[None]],
344
+ # action ID
345
+ str,
346
+ # the notification action to register; None for default & dismiss
347
+ UNNotificationAction | None,
348
+ UNNotificationCategoryOptions,
349
+ ]
350
+
351
+
352
+ _anyActionInfo: TypeAlias = (
353
+ "_PlainNotificationActionInfo | _TextNotificationActionInfo | _BuiltinActionInfo"
354
+ )
355
+
356
+
357
+ def _getActionInfo(o: object) -> _oneActionInfo | None:
358
+ handler: _anyActionInfo | None = getattr(o, _ACTION_INFO_ATTR, None)
359
+ if handler is None:
360
+ return None
361
+ appCallback: Any = o
362
+ actionID = handler.identifier
363
+ callback = handler._makeCallback(appCallback)
364
+ extraOptions = handler._extraOptions
365
+ return (callback, actionID, handler._toAction(), extraOptions)
366
+
367
+
368
+ def _setActionInfo[T](wrapt: T, actionInfo: _anyActionInfo) -> T:
369
+ setattr(wrapt, _ACTION_INFO_ATTR, actionInfo)
370
+ return wrapt
371
+
372
+
373
+ def _getAllActionInfos(t: type[object]) -> list[_oneActionInfo]:
374
+ result = []
375
+ for attr in dir(t):
376
+ actionInfo = _getActionInfo(getattr(t, attr, None))
377
+ if actionInfo is not None:
378
+ result.append(actionInfo)
379
+ return result
380
+
381
+
382
+ def _py2options(
383
+ foreground: bool,
384
+ destructive: bool,
385
+ authenticationRequired: bool,
386
+ ) -> UNNotificationActionOptions:
387
+ """
388
+ Convert some sensibly-named data types into UNNotificationActionOptions.
389
+ """
390
+ options = 0
391
+ if foreground:
392
+ options |= UNNotificationActionOptionForeground
393
+ if destructive:
394
+ options |= UNNotificationActionOptionDestructive
395
+ if authenticationRequired:
396
+ options |= UNNotificationActionOptionAuthenticationRequired
397
+ return options
398
+
399
+
400
+ @dataclass
401
+ class _PlainNotificationActionInfo:
402
+ identifier: str
403
+ title: str
404
+ foreground: bool
405
+ destructive: bool
406
+ authenticationRequired: bool
407
+ _extraOptions: UNNotificationCategoryOptions = 0
408
+
409
+ def _makeCallback[T](
410
+ self, appCallback: Callable[[T], Awaitable[None]]
411
+ ) -> Callable[[Any, UNNotificationResponse], Awaitable[None]]:
412
+ async def takesNotification(self: T, response: UNNotificationResponse) -> None:
413
+ await appCallback(self)
414
+ return None
415
+
416
+ return takesNotification
417
+
418
+ def _toAction(self) -> UNNotificationAction:
419
+ return UNNotificationAction.actionWithIdentifier_title_options_(
420
+ self.identifier,
421
+ self.title,
422
+ _py2options(
423
+ self.foreground,
424
+ self.destructive,
425
+ self.authenticationRequired,
426
+ ),
427
+ )
428
+
429
+
430
+ @dataclass
431
+ class _TextNotificationActionInfo:
432
+ identifier: str
433
+ title: str
434
+ foreground: bool
435
+ destructive: bool
436
+ authenticationRequired: bool
437
+ buttonTitle: str
438
+ textPlaceholder: str
439
+ _extraOptions: UNNotificationCategoryOptions = 0
440
+
441
+ def _makeCallback[T](
442
+ self, appCallback: Callable[[T, str], Awaitable[None]]
443
+ ) -> Callable[[Any, UNNotificationResponse], Awaitable[None]]:
444
+ async def takesNotification(self: T, response: UNNotificationResponse) -> None:
445
+ await appCallback(self, response.userText())
446
+ return None
447
+
448
+ return takesNotification
449
+
450
+ def _toAction(self) -> UNNotificationAction:
451
+ return UNTextInputNotificationAction.actionWithIdentifier_title_options_textInputButtonTitle_textInputPlaceholder_(
452
+ self.identifier,
453
+ self.title,
454
+ _py2options(
455
+ self.foreground,
456
+ self.destructive,
457
+ self.authenticationRequired,
458
+ ),
459
+ self.buttonTitle,
460
+ self.textPlaceholder,
461
+ )
462
+
463
+
464
+ @dataclass
465
+ class _BuiltinActionInfo:
466
+ identifier: str
467
+ _extraOptions: UNNotificationCategoryOptions
468
+
469
+ def _toAction(self) -> None:
470
+ return None
471
+
472
+ def _makeCallback[T](
473
+ self, appCallback: Callable[[T], Awaitable[None]]
474
+ ) -> Callable[[Any, UNNotificationResponse], Awaitable[None]]:
475
+ async def takesNotification(self: T, response: UNNotificationResponse) -> None:
476
+ await appCallback(self)
477
+ return None
478
+
479
+ return takesNotification
@@ -0,0 +1,245 @@
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
+ UNUserNotificationCenter as _UNUserNotificationCenter,
16
+ UNNotificationDefaultActionIdentifier as _UNNotificationDefaultActionIdentifier,
17
+ UNNotificationDismissActionIdentifier as _UNNotificationDismissActionIdentifier,
18
+ )
19
+
20
+ from quickmacapp._notifications import (
21
+ NotificationTranslator,
22
+ _BuiltinActionInfo,
23
+ _PlainNotificationActionInfo,
24
+ _setActionInfo,
25
+ _TextNotificationActionInfo,
26
+ _AppNotificationsCtxBuilder,
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
+ def add[NotifT](
144
+ self,
145
+ category: type[NotifT],
146
+ translator: NotificationTranslator[NotifT],
147
+ allowInCarPlay: bool = False,
148
+ hiddenPreviewsShowTitle: bool = False,
149
+ hiddenPreviewsShowSubtitle: bool = False,
150
+ ) -> Notifier[NotifT]: ...
151
+
152
+
153
+ def configureNotifications() -> _AbstractAsyncContextManager[NotificationConfig]:
154
+ """
155
+ Configure notifications for the current application.
156
+
157
+ This is an asynchronous (using Twisted's Deferred) context manager, run
158
+ with `with` statement, which works like this::
159
+
160
+ async with configureNotifications() as cfg:
161
+ notifier = cfg.add(MyNotificationData, MyNotificationLoader())
162
+
163
+ Each L{add <NotificationConfig.add>} invocation adds a category of
164
+ notifications you can send, and returns an object (a L{Notifier}) that can
165
+ send that category of notification.
166
+
167
+ At the end of the C{async with} block, the notification configuration is
168
+ finalized, its state is sent to macOS, and the categories of notification
169
+ your application can send is frozen for the rest of the lifetime of your
170
+ process; the L{Notifier} objects returned from L{add
171
+ <NotificationConfig.add>} are now active nad can be used. Note that you
172
+ may only call L{configureNotifications} once in your entire process, so you
173
+ will need to pass those notifiers elsewhere!
174
+
175
+ Each call to add requires 2 arguments: a notification-data class which
176
+ stores the sent notification's ID and any other ancillary data transmitted
177
+ along with it, and an object that can load and store that first class, when
178
+ notification responses from the operating system convey data that was
179
+ previously scheduled as a notification. In our example above, they can be
180
+ as simple as this::
181
+
182
+ class MyNotificationData:
183
+ id: str
184
+
185
+ class MyNotificationLoader:
186
+ def fromNotification(
187
+ self, notificationID: str, userData: dict[str, object]
188
+ ) -> MyNotificationData:
189
+ return MyNotificationData(notificationID)
190
+ def toNotification(
191
+ self,
192
+ notification: MyNotificationData,
193
+ ) -> tuple[str, dict[str, object]]:
194
+ return (notification.id, {})
195
+
196
+ Then, when you want to I{send} a notification, you can do::
197
+
198
+ await notifier.notifyAt(
199
+ aware(datetime.now(TZ) + timedelta(seconds=5), TZ),
200
+ MyNotificationData("my.notification.id.1"),
201
+ "Title Here",
202
+ "Subtitle Here",
203
+ )
204
+
205
+ And that will show the user a notification.
206
+
207
+ The C{MyNotificationData} class might seem simplistic to the point of
208
+ uselessness, and in this oversimplified case, it is! However, if you are
209
+ sending notifications to a user, you really need to be able to I{respond}
210
+ to notifications from a user, and that's where your notification data class
211
+ as well as L{responder} comes in. To respond to a notification when the
212
+ user clicks on it, you can add a method like so::
213
+
214
+ class MyNotificationData:
215
+ id: str
216
+
217
+ @response(identifier="response-action-1", title="Action 1")
218
+ async def responseAction1(self) -> None:
219
+ await answer("User pressed 'Action 1' button")
220
+
221
+ @response.default()
222
+ async def userClicked(self) -> None:
223
+ await answer("User clicked the notification.")
224
+
225
+ When sent with L{Notifier.notifyAt}, your C{MyNotificationData} class will
226
+ be serialized and deserialized with C{MyNotificationLoader.toNotification}
227
+ (converting your Python class into a macOS notification, to send along to
228
+ the OS) and C{MyNotificationLoader.fromNotification} (converting the data
229
+ sent along with the user's response back into a L{MyNotificationData}).
230
+
231
+ @note: If your app schedules a notification, then quits, when the user
232
+ responds (clicks on it, uses a button, dismisses it, etc) then the OS
233
+ will re-launch your application and send the notification data back in,
234
+ which is why all the serialization and deserialization is required.
235
+ Your process may have exited and thus the original notification will no
236
+ longer be around. However, if you are just running as a Python script,
237
+ piggybacking on the 'Python Launcher' app bundle, macOS will not be
238
+ able to re-launch your app. Notifications going back to the same
239
+ process seem to work okay, but note that as documented, macOS really
240
+ requires your application to have its own bundle and its own unique
241
+ CFBundleIdentifier in order to avoid any weird behavior.
242
+ """
243
+ return _AppNotificationsCtxBuilder(
244
+ _UNUserNotificationCenter.currentNotificationCenter(), None
245
+ )
@@ -1,13 +1,15 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: quickmacapp
3
- Version: 2025.3.16
3
+ Version: 2025.4.4
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
  ==============================
@@ -2,8 +2,11 @@ LICENSE
2
2
  README.rst
3
3
  pyproject.toml
4
4
  src/quickmacapp/__init__.py
5
+ src/quickmacapp/_background.py
5
6
  src/quickmacapp/_interactions.py
7
+ src/quickmacapp/_notifications.py
6
8
  src/quickmacapp/_quickapp.py
9
+ src/quickmacapp/notifications.py
7
10
  src/quickmacapp/py.typed
8
11
  src/quickmacapp.egg-info/PKG-INFO
9
12
  src/quickmacapp.egg-info/SOURCES.txt
@@ -1,4 +1,5 @@
1
1
  pyobjc-framework-Cocoa
2
2
  pyobjc-framework-ExceptionHandling
3
3
  pyobjc-framework-UserNotifications
4
+ datetype
4
5
  twisted[macos_platform,tls]
File without changes