├── .gitignore ├── .pydoctor.cfg ├── .readthedocs.yaml ├── LICENSE ├── README.rst ├── dev-requirements.in ├── dev-requirements.txt ├── docs ├── Makefile ├── api │ ├── .exists │ └── index.rst ├── conf.py ├── index.rst ├── make.bat ├── requirements.in └── requirements.txt ├── examples ├── MainMenu.nib ├── MainMenu.xib ├── eggs-and-milk.py ├── eggs-and-pw.py ├── eggs.acorn ├── eggs.png ├── menu-and-status.py └── notifications.py ├── mypy.ini ├── pyproject.toml ├── requirements.txt └── src └── quickmacapp ├── __init__.py ├── _background.py ├── _interactions.py ├── _notifications.py ├── _quickapp.py ├── notifications.py └── py.typed /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /build 3 | /docs/_build 4 | -------------------------------------------------------------------------------- /.pydoctor.cfg: -------------------------------------------------------------------------------- 1 | [tool:pydoctor] 2 | quiet=1 3 | warnings-as-errors=true 4 | project-name=Fritter 5 | project-url=https://github.com/glyph/fritter/ 6 | docformat=epytext 7 | theme=readthedocs 8 | intersphinx= 9 | https://docs.python.org/3/objects.inv 10 | https://cryptography.io/en/latest/objects.inv 11 | https://pyopenssl.readthedocs.io/en/stable/objects.inv 12 | https://hyperlink.readthedocs.io/en/stable/objects.inv 13 | https://twisted.org/constantly/docs/objects.inv 14 | https://twisted.org/incremental/docs/objects.inv 15 | https://python-hyper.org/projects/hyper-h2/en/stable/objects.inv 16 | https://priority.readthedocs.io/en/stable/objects.inv 17 | https://zopeinterface.readthedocs.io/en/latest/objects.inv 18 | https://automat.readthedocs.io/en/latest/objects.inv 19 | https://docs.twisted.org/en/stable/objects.inv 20 | project-base-dir=src/fritter 21 | html-output=docs/_build/api 22 | html-viewsource-base=https://github.com/glyph/fritter/tree/trunk/src 23 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-24.04 11 | tools: 12 | python: "3.13" 13 | # You can also specify other tool versions: 14 | # nodejs: "19" 15 | # rust: "1.64" 16 | # golang: "1.19" 17 | 18 | # Build documentation in the "docs/" directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | 22 | # Optionally build your docs in additional formats such as PDF and ePub 23 | formats: 24 | - htmlzip 25 | - pdf 26 | - epub 27 | 28 | # Optional but recommended, declare the Python requirements required 29 | # to build your documentation 30 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 31 | python: 32 | install: 33 | - requirements: docs/requirements.txt 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 4 | (c) 2023 Glyph 5 | (c) 2023 Palo Alto Networks 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | QuickMacApp 2 | ============================== 3 | 4 | .. note:: 5 | 6 | This is extremely rough and poorly documented at this point. While its 7 | public API is quite small to avoid **undue** churn, it may change quite 8 | rapidly and if you want to use this to ship an app you probably will want 9 | to contribute to it as well. 10 | 11 | Make it easier to write small applications for macOS in Python, using Twisted. 12 | 13 | To get a very basic status menu API: 14 | 15 | .. code:: 16 | python 17 | 18 | from quickmacapp import mainpoint, Status, answer, quit 19 | from twisted.internet.defer import Deferred 20 | 21 | @mainpoint() 22 | def app(reactor): 23 | s = Status("☀️ 💣") 24 | s.menu([("Do Something", lambda: Deferred.fromCoroutine(answer("something"))), 25 | ("Quit", quit)]) 26 | app.runMain() 27 | 28 | Packaging this into a working app bundle is currently left as an exercise for 29 | the reader. 30 | 31 | This was originally extracted from https://github.com/glyph/Pomodouroboros/ 32 | -------------------------------------------------------------------------------- /dev-requirements.in: -------------------------------------------------------------------------------- 1 | mypy 2 | mypy-zope 3 | 4 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --no-emit-index-url --strip-extras dev-requirements.in 6 | # 7 | mypy==1.15.0 8 | # via 9 | # -r dev-requirements.in 10 | # mypy-zope 11 | mypy-extensions==1.0.0 12 | # via mypy 13 | mypy-zope==1.0.11 14 | # via -r dev-requirements.in 15 | typing-extensions==4.12.2 16 | # via mypy 17 | zope-event==5.0 18 | # via zope-schema 19 | zope-interface==7.2 20 | # via 21 | # mypy-zope 22 | # zope-schema 23 | zope-schema==7.0.1 24 | # via mypy-zope 25 | 26 | # The following packages are considered to be unsafe in a requirements file: 27 | # setuptools 28 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api/.exists: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/QuickMacApp/58ad0786d6c8c02efed5d9ea5e0df217838ed689/docs/api/.exists -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | This file will be overwritten by the pydoctor build triggered at the end 5 | of the Sphinx build. 6 | 7 | It's a hack to be able to reference the API index page from inside Sphinx 8 | and have it as part of the TOC. 9 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'QuickMacApp' 10 | copyright = '2025, Glyph' 11 | author = 'Glyph' 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [ 17 | "sphinx.ext.intersphinx", 18 | "pydoctor.sphinx_ext.build_apidocs", 19 | "sphinx.ext.autosectionlabel", 20 | ] 21 | 22 | templates_path = ['_templates'] 23 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 24 | 25 | import pathlib, subprocess 26 | 27 | _project_root = pathlib.Path(__file__).parent.parent 28 | _source_root = _project_root / "src" 29 | 30 | _git_reference = subprocess.run( 31 | ["git", "rev-parse", "--abbrev-ref", "HEAD"], 32 | text=True, 33 | encoding="utf8", 34 | capture_output=True, 35 | check=True, 36 | ).stdout 37 | 38 | pydoctor_args = [ 39 | # pydoctor should not fail the sphinx build, we have another tox environment for that. 40 | "--intersphinx=https://docs.twisted.org/en/twisted-22.1.0/api/objects.inv", 41 | "--intersphinx=https://docs.python.org/3/objects.inv", 42 | "--intersphinx=https://zopeinterface.readthedocs.io/en/latest/objects.inv", 43 | # TODO: not sure why I have to specify these all twice. 44 | f"--config={_project_root}/.pydoctor.cfg", 45 | f"--html-viewsource-base=https://github.com/glyph/QuickMacApp/tree/{_git_reference}/src", 46 | f"--project-base-dir={_source_root}", 47 | "--html-output={outdir}/api", 48 | "--privacy=HIDDEN:quickmacapp.test.*", 49 | "--privacy=HIDDEN:quickmacapp.test", 50 | "--privacy=HIDDEN:**.__post_init__", 51 | str(_source_root / "quickmacapp"), 52 | ] 53 | pydoctor_url_path = "/en/{rtd_version}/api/" 54 | intersphinx_mapping = { 55 | "py3": ("https://docs.python.org/3", None), 56 | "zopeinterface": ("https://zopeinterface.readthedocs.io/en/latest", None), 57 | "twisted": ("https://docs.twisted.org/en/twisted-22.1.0/api", None), 58 | } 59 | 60 | # -- Options for HTML output ------------------------------------------------- 61 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 62 | 63 | html_theme = 'furo' 64 | html_static_path = ['_static'] 65 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. QuickMacApp documentation master file, created by 2 | sphinx-quickstart on Sun Apr 6 02:16:45 2025. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | QuickMacApp documentation 7 | ========================= 8 | 9 | Documentation is in progress. For now, please see the :py:mod:`API 10 | documentation `. 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | :caption: Contents: 15 | 16 | api/index 17 | 18 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | sphinx 2 | furo 3 | pydoctor 4 | 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --no-emit-index-url --strip-extras 6 | # 7 | alabaster==1.0.0 8 | # via sphinx 9 | attrs==25.3.0 10 | # via 11 | # pydoctor 12 | # twisted 13 | automat==24.8.1 14 | # via twisted 15 | babel==2.17.0 16 | # via sphinx 17 | beautifulsoup4==4.13.3 18 | # via furo 19 | cachecontrol==0.14.2 20 | # via 21 | # cachecontrol 22 | # pydoctor 23 | certifi==2025.1.31 24 | # via requests 25 | charset-normalizer==3.4.1 26 | # via requests 27 | configargparse==1.7 28 | # via pydoctor 29 | constantly==23.10.4 30 | # via twisted 31 | docutils==0.21.2 32 | # via 33 | # pydoctor 34 | # sphinx 35 | filelock==3.18.0 36 | # via cachecontrol 37 | furo==2024.8.6 38 | # via -r requirements.in 39 | hyperlink==21.0.0 40 | # via twisted 41 | idna==3.10 42 | # via 43 | # hyperlink 44 | # requests 45 | imagesize==1.4.1 46 | # via sphinx 47 | incremental==24.7.2 48 | # via twisted 49 | jinja2==3.1.6 50 | # via sphinx 51 | lunr==0.7.0.post1 52 | # via pydoctor 53 | markupsafe==3.0.2 54 | # via jinja2 55 | msgpack==1.1.0 56 | # via cachecontrol 57 | packaging==24.2 58 | # via sphinx 59 | platformdirs==4.3.7 60 | # via pydoctor 61 | pydoctor==24.11.2 62 | # via -r requirements.in 63 | pygments==2.19.1 64 | # via 65 | # furo 66 | # sphinx 67 | requests==2.32.3 68 | # via 69 | # cachecontrol 70 | # pydoctor 71 | # sphinx 72 | roman-numerals-py==3.1.0 73 | # via sphinx 74 | snowballstemmer==2.2.0 75 | # via sphinx 76 | soupsieve==2.6 77 | # via beautifulsoup4 78 | sphinx==8.2.3 79 | # via 80 | # -r requirements.in 81 | # furo 82 | # sphinx-basic-ng 83 | sphinx-basic-ng==1.0.0b2 84 | # via furo 85 | sphinxcontrib-applehelp==2.0.0 86 | # via sphinx 87 | sphinxcontrib-devhelp==2.0.0 88 | # via sphinx 89 | sphinxcontrib-htmlhelp==2.1.0 90 | # via sphinx 91 | sphinxcontrib-jsmath==1.0.1 92 | # via sphinx 93 | sphinxcontrib-qthelp==2.0.0 94 | # via sphinx 95 | sphinxcontrib-serializinghtml==2.0.0 96 | # via sphinx 97 | twisted==24.11.0 98 | # via pydoctor 99 | typing-extensions==4.13.1 100 | # via 101 | # beautifulsoup4 102 | # twisted 103 | urllib3==2.3.0 104 | # via 105 | # pydoctor 106 | # requests 107 | zope-interface==7.2 108 | # via twisted 109 | 110 | # The following packages are considered to be unsafe in a requirements file: 111 | # setuptools 112 | -------------------------------------------------------------------------------- /examples/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/QuickMacApp/58ad0786d6c8c02efed5d9ea5e0df217838ed689/examples/MainMenu.nib -------------------------------------------------------------------------------- /examples/MainMenu.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 31 | 37 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 115 | 334 | 596 | 628 | 653 | 665 | 666 | 667 | 668 | 669 | 670 | -------------------------------------------------------------------------------- /examples/eggs-and-milk.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import os 5 | 6 | import AppKit 7 | 8 | from twisted.internet.interfaces import IReactorTime 9 | from twisted.internet.defer import Deferred 10 | from twisted.python.failure import Failure 11 | 12 | from quickmacapp import mainpoint, Status, ask, choose, answer, quit 13 | 14 | resultTemplate = """ 15 | You need to go to the store to get more eggs in {eggDays} days 16 | You need to go to the store to get more milk in {milkDays} days 17 | You will need to bring around ${eggPrice} to get the same amount of eggs 18 | You will need to bring around ${milkPrice} to get the same amount of milk 19 | Thank you 20 | """ 21 | 22 | # Prices from reference implementation, possibly accurate circa 2002 23 | eggUnitPrice = 0.09083 24 | milkOuncePrice = 0.05 25 | 26 | 27 | def alwaysFloat(value: str | None) -> float: 28 | try: 29 | return float(value or "nan") 30 | except: 31 | return float("nan") 32 | 33 | 34 | async def eggsAndMilkMinder() -> None: 35 | eggCount = alwaysFloat(await ask("Enter the number of eggs you have")) 36 | eatEggCount = alwaysFloat(await ask("Enter the number of eggs you eat per day")) 37 | milkCount = alwaysFloat(await ask("Enter the number of ounces of milk you have")) 38 | drinkMilkCount = alwaysFloat( 39 | await ask("Enter in the amount of milk you drink each day in ounces") 40 | ) 41 | await answer( 42 | resultTemplate.format( 43 | eggDays=(eggCount / eatEggCount), 44 | milkDays=(milkCount / drinkMilkCount), 45 | eggPrice=eggCount * eggUnitPrice, 46 | milkPrice=milkCount * milkOuncePrice, 47 | ) 48 | ) 49 | hashBrowns = await choose( 50 | [(True, "y"), (False, "n")], 51 | "Would you like to also make some delicious hashbrowns?", 52 | ) 53 | await answer( 54 | ( 55 | ( 56 | "Then you will need to get some potatoes and grate them," 57 | " also some onions and cook it all so it's delicious" 58 | ) 59 | if hashBrowns 60 | else ( 61 | "Suit yourself, but hashbrowns are delicious," 62 | " you should definitely have them sometime" 63 | ) 64 | ), 65 | ) 66 | 67 | 68 | @mainpoint() 69 | def app(reactor: IReactorTime) -> None: 70 | app = AppKit.NSApplication.sharedApplication() 71 | app.setActivationPolicy_(AppKit.NSApplicationActivationPolicyAccessory) 72 | status = Status( 73 | image=AppKit.NSImage.alloc().initByReferencingFile_( 74 | str(pathlib.Path(__file__).parent / "eggs.png") 75 | ), 76 | ) 77 | status.menu( 78 | [ 79 | ( 80 | "About", 81 | lambda: Deferred.fromCoroutine( 82 | answer( 83 | "Eggs And Milk Minder 1.0c", 84 | "With apologies to Roast Beef Kazenzakis", 85 | ) 86 | ), 87 | ), 88 | ( 89 | "Calculate Eggs And Milk", 90 | lambda: Deferred.fromCoroutine(eggsAndMilkMinder()), 91 | ), 92 | ("Quit", quit), 93 | ] 94 | ) 95 | 96 | 97 | if __name__ == "__main__": 98 | app.runMain() 99 | -------------------------------------------------------------------------------- /examples/eggs-and-pw.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import os 5 | 6 | import AppKit 7 | 8 | from twisted.internet.interfaces import IReactorTime 9 | from twisted.internet.defer import Deferred 10 | from twisted.python.failure import Failure 11 | 12 | from quickmacapp import mainpoint, Status, answer, quit, getpass 13 | 14 | async def eggsPassword() -> None: 15 | pw = await getpass("please enter your egg password") 16 | await answer (f"I tricked you, your password is {pw}") 17 | 18 | 19 | @mainpoint() 20 | def app(reactor: IReactorTime) -> None: 21 | app = AppKit.NSApplication.sharedApplication() 22 | app.setActivationPolicy_(AppKit.NSApplicationActivationPolicyAccessory) 23 | status = Status("🥚🔒") 24 | status.menu( 25 | [ 26 | ( 27 | "About", 28 | lambda: Deferred.fromCoroutine( 29 | answer( 30 | "Secure Your Eggs In One Basket", 31 | ) 32 | ), 33 | ), 34 | ( 35 | "Enter Password for Eggs", 36 | lambda: Deferred.fromCoroutine(eggsPassword()).addErrback(lambda f: f.printTraceback()), 37 | ), 38 | ("Quit", quit), 39 | ] 40 | ) 41 | 42 | 43 | if __name__ == "__main__": 44 | app.runMain() 45 | -------------------------------------------------------------------------------- /examples/eggs.acorn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/QuickMacApp/58ad0786d6c8c02efed5d9ea5e0df217838ed689/examples/eggs.acorn -------------------------------------------------------------------------------- /examples/eggs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/QuickMacApp/58ad0786d6c8c02efed5d9ea5e0df217838ed689/examples/eggs.png -------------------------------------------------------------------------------- /examples/menu-and-status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import pathlib 3 | import os 4 | 5 | import AppKit 6 | 7 | from quickmacapp import mainpoint, Status 8 | 9 | @mainpoint() 10 | def app(reactor: IReactorTime) -> None: 11 | AppKit.NSApplication.sharedApplication().setActivationPolicy_( 12 | AppKit.NSApplicationActivationPolicyRegular 13 | ) 14 | 15 | def check_sun(): 16 | print("Sun is still not destroyed") 17 | def destroy_sun(): 18 | print("Sun destruction capabalities still not deployed") 19 | 20 | status = Status("☀️ 💣") 21 | status.menu([("Check sun", check_sun), ("Destroy sun", destroy_sun)]) 22 | 23 | nib_file = pathlib.Path(__file__).parent / "MainMenu.nib" 24 | nib_data = AppKit.NSData.dataWithContentsOfFile_(os.fspath(nib_file)) 25 | AppKit.NSNib.alloc().initWithNibData_bundle_( 26 | nib_data, None 27 | ).instantiateWithOwner_topLevelObjects_(None, None) 28 | 29 | 30 | # When I'm no longer bootstrapping the application I'll want to *not* 31 | # unconditionally activate here, just have normal launch behavior. 32 | AppKit.NSApplication.sharedApplication().activateIgnoringOtherApps_(True) 33 | 34 | if __name__ == "__main__": 35 | app.runMain() 36 | -------------------------------------------------------------------------------- /examples/notifications.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from zoneinfo import ZoneInfo 3 | 4 | from datetype import aware 5 | from datetime import datetime, timedelta 6 | from quickmacapp import Status, mainpoint, quit, answer 7 | from quickmacapp.notifications import Notifier, configureNotifications, response 8 | from twisted.internet.defer import Deferred 9 | 10 | 11 | @dataclass 12 | class category1: 13 | notificationID: str 14 | state: list[str] 15 | 16 | @response(identifier="action1", title="First Action") 17 | async def action1(self) -> None: 18 | await answer(f"{self.notificationID}\nhere's an answer! {self.state}") 19 | 20 | @response(identifier="action2", title="Text Action").text() 21 | async def action2(self, text: str) -> None: 22 | await answer(f"{self.notificationID}\ngot some text\n{text}\n{self.state}") 23 | 24 | @response.default() 25 | async def defaulted(self) -> None: 26 | await answer(f"{self.notificationID}\ndefaulted\n{self.state}") 27 | 28 | @response.dismiss() 29 | async def dismiss(self) -> None: 30 | await answer(f"{self.notificationID}\ndismissed\n{self.state}") 31 | 32 | 33 | class ExampleTranslator: 34 | def fromNotification( 35 | self, notificationID: str, userData: dict[str, list[str]] 36 | ) -> category1: 37 | return category1(notificationID, userData["stateList"]) 38 | 39 | def toNotification( 40 | self, notification: category1 41 | ) -> tuple[str, dict[str, list[str]]]: 42 | return (notification.notificationID, {"stateList": notification.state}) 43 | 44 | 45 | async def setupNotifications() -> Notifier[category1]: 46 | async with configureNotifications() as n: 47 | cat1notify = n.add(category1, ExampleTranslator()) 48 | return cat1notify 49 | 50 | 51 | @mainpoint() 52 | def app(reactor): 53 | async def stuff() -> None: 54 | s = Status("💁💬") 55 | n = 0 56 | 57 | async def doNotify() -> None: 58 | nonlocal n 59 | n += 1 60 | await cat1notify.notifyAt( 61 | aware(datetime.now(ZoneInfo("US/Pacific")), ZoneInfo) 62 | + timedelta(seconds=5), 63 | category1(f"just.testing.{n}", ["some", "words"]), 64 | f"Just Testing This Out ({n})", 65 | "Here's The Notification", 66 | ) 67 | 68 | async def doCancel() -> None: 69 | cat1notify.undeliver(category1(f"just.testing.{n}", ["ignored"])) 70 | 71 | cat1notify = await setupNotifications() 72 | s.menu( 73 | [ 74 | ("Notify", lambda: Deferred.fromCoroutine(doNotify())), 75 | ("Cancel", lambda: Deferred.fromCoroutine(doCancel())), 76 | ("Quit", quit), 77 | ] 78 | ) 79 | 80 | Deferred.fromCoroutine(stuff()) 81 | 82 | 83 | app.runMain() 84 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | namespace_packages = True 3 | disallow_subclassing_any = False 4 | plugins=mypy_zope:plugin 5 | 6 | # making our way to 'strict' 7 | warn_return_any = True 8 | 9 | strict_optional = True 10 | warn_no_return = True 11 | warn_unused_configs = True 12 | warn_unused_ignores = True 13 | warn_redundant_casts = True 14 | no_implicit_optional = True 15 | 16 | [not-yet-mypy] 17 | disallow_subclassing_any = True 18 | disallow_untyped_defs = True 19 | disallow_any_generics = True 20 | disallow_any_unimported = True 21 | 22 | [mypy-Foundation.*] 23 | ignore_missing_imports = True 24 | 25 | [mypy-UserNotifications.*] 26 | ignore_missing_imports = True 27 | 28 | [mypy-AppKit.*] 29 | ignore_missing_imports = True 30 | 31 | [mypy-CoreMedia.*] 32 | ignore_missing_imports = True 33 | 34 | [mypy-PyObjCTools.*] 35 | ignore_missing_imports = True 36 | 37 | [mypy-objc.*] 38 | ignore_missing_imports = True 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools.package-data] 6 | "*" = ["py.typed"] 7 | 8 | [project] 9 | name = "quickmacapp" 10 | description = "Make it easier to write Mac apps in Python" 11 | readme = "README.rst" 12 | version = "2025.04.15" 13 | dependencies = [ 14 | "pyobjc-framework-Cocoa", 15 | "pyobjc-framework-ExceptionHandling", 16 | "pyobjc-framework-UserNotifications", 17 | "datetype", 18 | "twisted[tls,macos_platform]", 19 | ] 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --no-emit-index-url --strip-extras 6 | # 7 | anyio==4.9.0 8 | # via httpx 9 | appdirs==1.4.4 10 | # via twisted 11 | attrs==25.3.0 12 | # via 13 | # hypothesis 14 | # service-identity 15 | # twisted 16 | automat==24.8.1 17 | # via twisted 18 | bcrypt==4.3.0 19 | # via twisted 20 | certifi==2025.1.31 21 | # via 22 | # httpcore 23 | # httpx 24 | cffi==1.17.1 25 | # via cryptography 26 | constantly==23.10.4 27 | # via twisted 28 | cryptography==44.0.2 29 | # via 30 | # pyopenssl 31 | # service-identity 32 | # twisted 33 | cython-test-exception-raiser==1.0.2 34 | # via twisted 35 | datetype==2025.2.13 36 | # via quickmacapp (pyproject.toml) 37 | h11==0.14.0 38 | # via httpcore 39 | h2==4.2.0 40 | # via 41 | # httpx 42 | # twisted 43 | hpack==4.1.0 44 | # via h2 45 | httpcore==1.0.7 46 | # via httpx 47 | httpx==0.28.1 48 | # via twisted 49 | hyperframe==6.1.0 50 | # via h2 51 | hyperlink==21.0.0 52 | # via twisted 53 | hypothesis==6.130.8 54 | # via twisted 55 | idna==3.10 56 | # via 57 | # anyio 58 | # httpx 59 | # hyperlink 60 | # twisted 61 | incremental==24.7.2 62 | # via twisted 63 | priority==1.3.0 64 | # via twisted 65 | pyasn1==0.6.1 66 | # via 67 | # pyasn1-modules 68 | # service-identity 69 | pyasn1-modules==0.4.2 70 | # via service-identity 71 | pycparser==2.22 72 | # via cffi 73 | pyhamcrest==2.1.0 74 | # via twisted 75 | pyobjc-core==11.0 76 | # via 77 | # pyobjc-framework-cfnetwork 78 | # pyobjc-framework-cocoa 79 | # pyobjc-framework-exceptionhandling 80 | # pyobjc-framework-usernotifications 81 | # twisted 82 | pyobjc-framework-cfnetwork==11.0 83 | # via twisted 84 | pyobjc-framework-cocoa==11.0 85 | # via 86 | # pyobjc-framework-cfnetwork 87 | # pyobjc-framework-exceptionhandling 88 | # pyobjc-framework-usernotifications 89 | # quickmacapp (pyproject.toml) 90 | # twisted 91 | pyobjc-framework-exceptionhandling==11.0 92 | # via quickmacapp (pyproject.toml) 93 | pyobjc-framework-usernotifications==11.0 94 | # via quickmacapp (pyproject.toml) 95 | pyopenssl==25.0.0 96 | # via twisted 97 | pyserial==3.5 98 | # via twisted 99 | service-identity==24.2.0 100 | # via twisted 101 | sniffio==1.3.1 102 | # via anyio 103 | sortedcontainers==2.4.0 104 | # via hypothesis 105 | twisted==24.11.0 106 | # via quickmacapp (pyproject.toml) 107 | typing-extensions==4.13.1 108 | # via twisted 109 | zope-interface==7.2 110 | # via twisted 111 | 112 | # The following packages are considered to be unsafe in a requirements file: 113 | # setuptools 114 | -------------------------------------------------------------------------------- /src/quickmacapp/__init__.py: -------------------------------------------------------------------------------- 1 | from ._quickapp import Actionable, Status, mainpoint, menu, quit 2 | from ._interactions import ask, choose, answer, getpass 3 | from ._background import dockIconWhenVisible 4 | 5 | __all__ = [ 6 | "Actionable", 7 | "Status", 8 | "mainpoint", 9 | "menu", 10 | "quit", 11 | "ask", 12 | "choose", 13 | "answer", 14 | "getpass", 15 | "dockIconWhenVisible", 16 | ] 17 | -------------------------------------------------------------------------------- /src/quickmacapp/_background.py: -------------------------------------------------------------------------------- 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() 156 | -------------------------------------------------------------------------------- /src/quickmacapp/_interactions.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Iterator, TypeVar 2 | 3 | from AppKit import ( 4 | NSAlertFirstButtonReturn, 5 | NSAlertSecondButtonReturn, 6 | NSAlertThirdButtonReturn, 7 | NSAlert, 8 | NSApp, 9 | NSTextField, 10 | NSSecureTextField, 11 | NSCenterTextAlignment, 12 | ) 13 | from Foundation import ( 14 | NSRunLoop, 15 | NSRect, 16 | ) 17 | from twisted.internet.defer import Deferred 18 | 19 | 20 | NSModalResponse = int 21 | T = TypeVar("T") 22 | 23 | 24 | def asyncModal(alert: NSAlert) -> Deferred[NSModalResponse]: 25 | """ 26 | Run an NSAlert asynchronously. 27 | """ 28 | d: Deferred[NSModalResponse] = Deferred() 29 | 30 | def runAndReport() -> None: 31 | try: 32 | NSApp().activateIgnoringOtherApps_(True) 33 | result = alert.runModal() 34 | except: 35 | d.errback() 36 | else: 37 | d.callback(result) 38 | 39 | NSRunLoop.currentRunLoop().performBlock_(runAndReport) 40 | return d 41 | 42 | 43 | def _alertReturns() -> Iterator[NSModalResponse]: 44 | """ 45 | Enumerate the values used by NSAlert for return values in the order of the 46 | buttons that occur. 47 | """ 48 | yield NSAlertFirstButtonReturn 49 | yield NSAlertSecondButtonReturn 50 | yield NSAlertThirdButtonReturn 51 | i = 1 52 | while True: 53 | yield NSAlertThirdButtonReturn + i 54 | i += 1 55 | 56 | 57 | async def choose(values: Iterable[tuple[T, str]], question: str, description: str="") -> T: 58 | """ 59 | Prompt the user to choose between the given values, on buttons labeled in 60 | the given way. 61 | """ 62 | msg = NSAlert.alloc().init() 63 | msg.setMessageText_(question) 64 | msg.setInformativeText_(description) 65 | potentialResults = {} 66 | for (value, label), alertReturn in zip(values, _alertReturns()): 67 | msg.addButtonWithTitle_(label) 68 | potentialResults[alertReturn] = value 69 | msg.layout() 70 | return potentialResults[await asyncModal(msg)] 71 | 72 | 73 | async def getpass(question: str, description: str="") -> str | None: 74 | # set a sample value to get a reasonable visual width 75 | txt = NSSecureTextField.textFieldWithString_("testing " * 4) 76 | # clear it out because of course we don't want to use that value 77 | txt.setStringValue_("") 78 | txt.setAlignment_(NSCenterTextAlignment) 79 | txt.setMaximumNumberOfLines_(5) 80 | return await _ask(question, description, txt) 81 | 82 | 83 | async def ask(question: str, description: str="", defaultValue: str=""): 84 | # TODO: version of this with a NSSecureTextField for entering passwords 85 | txt = NSTextField.alloc().initWithFrame_(NSRect((0, 0), (200, 100))) 86 | txt.setMaximumNumberOfLines_(5) 87 | txt.setStringValue_(defaultValue) 88 | return await _ask(question, description, txt) 89 | 90 | 91 | async def _ask(question: str, description: str, txt: NSTextField) -> str | None: 92 | """ 93 | Prompt the user for a short string of text. 94 | """ 95 | msg = NSAlert.alloc().init() 96 | msg.addButtonWithTitle_("OK") 97 | msg.addButtonWithTitle_("Cancel") 98 | msg.setMessageText_(question) 99 | msg.setInformativeText_(description) 100 | 101 | msg.setAccessoryView_(txt) 102 | msg.window().setInitialFirstResponder_(txt) 103 | msg.layout() 104 | 105 | response: NSModalResponse = await asyncModal(msg) 106 | 107 | if response == NSAlertFirstButtonReturn: 108 | result: str = txt.stringValue() 109 | return result 110 | 111 | return None 112 | 113 | 114 | async def answer(message: str, description: str="") -> None: 115 | """ 116 | Give the user a message. 117 | """ 118 | msg = NSAlert.alloc().init() 119 | msg.setMessageText_(message) 120 | msg.setInformativeText_(description) 121 | # msg.addButtonWithTitle("OK") 122 | msg.layout() 123 | 124 | await asyncModal(msg) 125 | -------------------------------------------------------------------------------- /src/quickmacapp/_notifications.py: -------------------------------------------------------------------------------- 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 499 | -------------------------------------------------------------------------------- /src/quickmacapp/_quickapp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | import traceback 6 | from typing import Callable, Protocol, Any 7 | 8 | from objc import ivar, IBAction, super 9 | 10 | from Foundation import ( 11 | NSObject, 12 | NSException, 13 | ) 14 | 15 | from AppKit import ( 16 | NSApp, 17 | NSApplication, 18 | NSEvent, 19 | NSResponder, 20 | NSMenu, 21 | NSImage, 22 | NSMenuItem, 23 | NSStatusBar, 24 | NSVariableStatusItemLength, 25 | ) 26 | 27 | from PyObjCTools.Debugging import _run_atos, isPythonException 28 | from ExceptionHandling import ( # type:ignore 29 | NSStackTraceKey, 30 | ) 31 | 32 | 33 | class Actionable(NSObject): 34 | """ 35 | Wrap a Python no-argument function call in an NSObject with a C{doIt:} 36 | method. 37 | """ 38 | _thunk: Callable[[], None] 39 | 40 | def initWithFunction_(self, thunk: Callable[[], None]) -> Actionable: 41 | """ 42 | Remember the given callable. 43 | 44 | @param thunk: the callable to run in L{doIt_}. 45 | """ 46 | self._thunk = thunk 47 | return self 48 | 49 | @IBAction 50 | def doIt_(self, sender: object) -> None: 51 | """ 52 | Call the given callable; exposed as an C{IBAction} in case you want IB 53 | to be able to see it. 54 | """ 55 | self._thunk() 56 | 57 | 58 | def menu(title: str, items: list[tuple[str, Callable[[], object]]]) -> NSMenu: 59 | """ 60 | Construct an NSMenu from a list of tuples describing it. 61 | 62 | @note: Since NSMenu's target attribute is a weak reference, the callable 63 | objects here are made immortal via an unpaired call to C{retain} on 64 | their L{Actionable} wrappers. 65 | 66 | @param items: list of pairs of (menu item's name, click action). 67 | 68 | @return: a new Menu tha is not attached to anything. 69 | """ 70 | result = NSMenu.alloc().initWithTitle_(title) 71 | for (subtitle, thunk) in items: 72 | item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 73 | subtitle, "doIt:", subtitle[0].lower() 74 | ) 75 | item.setTarget_(Actionable.alloc().initWithFunction_(thunk).retain()) 76 | result.addItem_(item) 77 | result.update() 78 | return result 79 | 80 | 81 | class Status: 82 | """ 83 | Application status (top menu bar, on right) 84 | """ 85 | 86 | def __init__(self, text: str | None = None, image: NSImage | None = None) -> None: 87 | """ 88 | Create a L{Status} with some text to use as its label. 89 | 90 | @param text: The initial label displayed in the menu bar. 91 | """ 92 | self.item = NSStatusBar.systemStatusBar().statusItemWithLength_( 93 | NSVariableStatusItemLength 94 | ) 95 | self.item.button().setEnabled_(True) 96 | if image is not None: 97 | self.item.button().setImage_(image) 98 | elif text is None: 99 | from __main__ import __file__ as default 100 | text = os.path.basename(default) 101 | if text is not None: 102 | self.item.button().setTitle_(text) 103 | 104 | def menu(self, items: list[tuple[str, Callable[[], object]]]) -> None: 105 | """ 106 | Set the status drop-down menu. 107 | 108 | @param items: list of pairs of (menu item's name, click action). 109 | 110 | @see: L{menu} 111 | """ 112 | self.item.setMenu_(menu(self.item.title(), items)) 113 | 114 | 115 | def fmtPythonException(exception: NSException) -> str: 116 | """ 117 | Format an NSException containing a wrapped PyObjC python exception. 118 | """ 119 | userInfo = exception.userInfo() 120 | return "*** Python exception discarded!\n" + "".join( 121 | traceback.format_exception( 122 | userInfo["__pyobjc_exc_type__"], 123 | userInfo["__pyobjc_exc_value__"], 124 | userInfo["__pyobjc_exc_traceback__"], 125 | ) 126 | ) 127 | 128 | 129 | def fmtObjCException(exception: NSException) -> str: 130 | """ 131 | Format an Objective C exception which I{does not} contain a wrapped Python 132 | exception. 133 | 134 | @return: our best effort to format the stack trace for the given exception. 135 | """ 136 | stacktrace = None 137 | 138 | try: 139 | stacktrace = exception.callStackSymbols() 140 | except AttributeError: 141 | pass 142 | 143 | if stacktrace is None: 144 | stack = exception.callStackReturnAddresses() 145 | if stack: 146 | pipe = _run_atos(" ".join(hex(v) for v in stack)) 147 | if pipe is None: 148 | return "ObjC exception reporting error: cannot run atos" 149 | 150 | stacktrace = pipe.readlines() 151 | stacktrace.reverse() 152 | pipe.close() 153 | 154 | if stacktrace is None: 155 | userInfo = exception.userInfo() 156 | stack = userInfo.get(NSStackTraceKey) 157 | if not stack: 158 | return "ObjC exception reporting error: cannot get stack trace" 159 | 160 | pipe = _run_atos(stack) 161 | if pipe is None: 162 | return "ObjC exception reporting error: cannot run atos" 163 | 164 | stacktrace = pipe.readlines() 165 | stacktrace.reverse() 166 | pipe.close() 167 | 168 | return ( 169 | "*** ObjC exception '%s' (reason: '%s') discarded\n" 170 | % (exception.name(), exception.reason()) 171 | + "Stack trace (most recent call last):\n" 172 | + "\n".join([" " + line for line in stacktrace]) 173 | ) 174 | 175 | 176 | class QuickApplication(NSApplication): 177 | """ 178 | QuickMacApp's main application class. 179 | 180 | @ivar keyEquivalentHandler: Set this attribute to a custom C{NSResponder} 181 | if you want to handle key equivalents outside the responder chain. (I 182 | believe this is necessary in some apps because the responder chain can 183 | be more complicated in LSUIElement apps, but there might be a better 184 | way to do this.) 185 | """ 186 | keyEquivalentHandler: NSResponder = ivar() 187 | 188 | def sendEvent_(self, event: NSEvent) -> None: 189 | """ 190 | Hand off any incoming events to the key equivalent handler. 191 | """ 192 | if self.keyEquivalentHandler is not None: 193 | if self.keyEquivalentHandler.performKeyEquivalent_(event): 194 | return 195 | super().sendEvent_(event) 196 | 197 | def reportException_(self, exception): 198 | """ 199 | Override C{[NSApplication reportException:]} to report exceptions more 200 | legibly to Python developers. 201 | """ 202 | if isPythonException(exception): 203 | print(fmtPythonException(exception)) 204 | else: 205 | print(fmtObjCException(exception)) 206 | sys.stdout.flush() 207 | 208 | 209 | class MainRunner(Protocol): 210 | """ 211 | A function which has been decorated with a runMain attribute. 212 | """ 213 | def __call__(self, reactor: Any) -> None: 214 | """ 215 | @param reactor: A Twisted reactor, which provides the usual suspects of 216 | C{IReactorTime}, C{IReactorTCP}, etc. 217 | """ 218 | 219 | runMain: Callable[[], None] 220 | 221 | def mainpoint() -> Callable[[Callable[[Any], None]], MainRunner]: 222 | """ 223 | Add a .runMain attribute to function 224 | 225 | @return: A decorator that adds a .runMain attribute to a function. 226 | 227 | The runMain attribute starts a reactor and calls the original function 228 | with a running, initialized, reactor. 229 | """ 230 | def wrapup(appmain: Callable[[Any], None]) -> MainRunner: 231 | def doIt() -> None: 232 | from twisted.internet import cfreactor 233 | import PyObjCTools.AppHelper 234 | 235 | QuickApplication.sharedApplication() 236 | 237 | def myRunner() -> None: 238 | PyObjCTools.Debugging.installVerboseExceptionHandler() 239 | PyObjCTools.AppHelper.runEventLoop() 240 | 241 | def myMain() -> None: 242 | appmain(reactor) 243 | 244 | reactor = cfreactor.install(runner=myRunner) 245 | reactor.callWhenRunning(myMain) 246 | reactor.run() 247 | os._exit(0) 248 | 249 | appMainAsRunner: MainRunner = appmain # type:ignore[assignment] 250 | appMainAsRunner.runMain = doIt 251 | return appMainAsRunner 252 | 253 | return wrapup 254 | 255 | 256 | def quit() -> None: 257 | """ 258 | Quit. 259 | """ 260 | NSApp().terminate_(NSApp()) 261 | -------------------------------------------------------------------------------- /src/quickmacapp/notifications.py: -------------------------------------------------------------------------------- 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 } 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 | } 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 | ) 277 | -------------------------------------------------------------------------------- /src/quickmacapp/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/QuickMacApp/58ad0786d6c8c02efed5d9ea5e0df217838ed689/src/quickmacapp/py.typed --------------------------------------------------------------------------------