Skip to content

Expose macOS app lifecycle notifications as overridable hooks - #4599

Merged
freakboy3742 merged 5 commits into
beeware:mainfrom
TensorDevLJ:macos-app-lifecycle-hooks
Jul 29, 2026
Merged

Expose macOS app lifecycle notifications as overridable hooks#4599
freakboy3742 merged 5 commits into
beeware:mainfrom
TensorDevLJ:macos-app-lifecycle-hooks

Conversation

@TensorDevLJ

@TensorDevLJ TensorDevLJ commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes (partly) #4478.

What this does

macOS notifies an app of lifecycle events (launch, activation, hide/unhide,
screen changes, termination, etc.) by calling methods on an
NSApplicationDelegate instance. Today, the logic for the handful of events
Toga cares about lives directly inside AppDelegate methods, with no way for
an app author to hook into it, and most native lifecycle notifications aren't
wired up at all.

Per the pattern proposed in #4478, this PR (scoped to Cocoa/macOS only, per
the maintainer's note that per-platform PRs are acceptable):

  • Moves the logic out of AppDelegate's existing lifecycle methods
    (applicationDidFinishLaunching:, applicationWillHide:,
    applicationDidUnhide:) into same-named cocoa_-prefixed methods on
    toga_cocoa's App implementation class. AppDelegate methods are now
    thin passthroughs that forward all arguments unchanged.
  • Adds delegate methods + cocoa_ implementations for every native lifecycle
    notification Cocoa didn't previously handle (applicationWillFinishLaunching:,
    application{Will,Did}{Become,Resign}Active:, applicationDidHide:,
    applicationWillUnhide:, applicationDidChangeScreenParameters:,
    applicationShouldTerminate:, applicationWillTerminate:), each defaulting
    to a documented no-op (or, for applicationShouldTerminate:, to allowing
    termination immediately — matching the native default).
  • Adds NSTerminateNow/NSTerminateCancel/NSTerminateLater constants.

In the absence of a cross-platform lifecycle API, an app can now hook into
one of these events on macOS:

import sys

if sys.platform == "darwin":
    from toga_cocoa.app import App as NativeApp

    def my_did_become_active(self, notification):
        # ... custom logic before base class implementation ...
        NativeApp.cocoa_applicationDidBecomeActive(self, notification)
        # ... custom logic after base class implementation ...

    NativeApp.cocoa_applicationDidBecomeActive = my_did_become_active

Note the override is applied to the App class, not an instance — the
docs explain why (Python only auto-binds self for methods found via the
class, not for plain functions assigned to an instance attribute).

Testing

  • Added testbed/tests/app/test_cocoa.py (macOS-only): verifies every new
    no-op hook is a safe no-op by default and can be overridden and observes
    the correct notification argument, plus dedicated default/override tests
    for applicationShouldTerminate:.
  • Added a trigger_lifecycle_notification() probe helper in
    cocoa/tests_backend/app.py to invoke delegate methods directly.
  • Existing behavior for applicationDidFinishLaunching:,
    applicationWillHide:/applicationDidUnhide: is unchanged (verified by
    the existing hide/unhide coverage in testbed/tests/app/test_desktop.py)
    and confirmed with a manual script exercising the real
    AppDelegate/App wiring via rubicon-objc.
  • tox -m test-core still passes at 100% coverage (unaffected, as expected
    for a Cocoa-only change).
  • tox -e docs-en builds cleanly with the new reference section.
  • CI: Testbed (macOS-arm64) and Testbed (macOS-x86_64) both pass with
    this change.

Docs

Added a "Platform-specific APIs" section to
docs/en/reference/platforms/macOS.md documenting all 13 hooks, with a
working usage example. Wired cocoa/src into the mkdocstrings build
(docs/config.yml, tox.ini) so the new methods can be referenced.

Status

  • Existing tests pass; core coverage unaffected (100%).
  • Changelog note added (changes/4478.feature.md).
  • pre-commit run --all-files passes on changed files.
  • Verified on CI: macOS testbed jobs pass. (The textual-linux,
    textual-macOS and linux-wayland-qt testbed failures on this PR are
    pre-existing, unrelated widget-layout flakiness — textual-linux is
    currently failing the same way on main, and the qt failure is an
    unrelated Selection widget sizing assertion. This PR does not touch
    those backends.)

PR Checklist:

  • I will abide by the BeeWare Code of Conduct
  • I have read and have followed the CONTRIBUTING.md file
  • This PR was generated or assisted using an AI tool

Assisted-by: Cursor

The Cocoa AppDelegate now hands off every native app lifecycle
notification (launch, activation, hide/unhide, screen changes,
termination) to a same-named cocoa_ method on the App implementation
class, most defaulting to a no-op. In the absence of a cross-platform
lifecycle API, this lets apps hook into these events on macOS by
overriding the corresponding cocoa_ method on the App class.

Fixes beeware#4478.

Co-authored-by: Cursor <cursoragent@cursor.com>
@freakboy3742 freakboy3742 added the basic checks failing Pull request does not pass basic quality checks label Jul 27, 2026
The Cocoa lifecycle-hook tests were gated on toga.platform.current_platform,
which only reflects the host OS. The "textual-macOS" CI job runs the Textual
backend on a macOS runner, so the tests weren't skipped there and failed with
AttributeError/ModuleNotFoundError since app._impl isn't a toga_cocoa.App.
Gate on toga.backend instead, which reflects the active backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
@TensorDevLJ

Copy link
Copy Markdown
Contributor Author

The remaining failing check, Testbed (macOS-x86_64), is unrelated to this change: it's tests/window/test_window.py::test_window_state_change[MainWindow-second_window_kwargs0-WindowState.FULLSCREEN-WindowState.MAXIMIZED], a pre-existing, known-intermittent failure tracked in #3897 ("Intermittent failure in macOS x86-64 test_window_state_change"). It retried 6 times via the testbed's flaky-test retry mechanism before failing, consistent with that existing issue, and doesn't touch anything related to app lifecycle/window state that this PR changes.

The textual-linux, textual-macOS, and linux-wayland-qt failures from the initial run have since been fixed/resolved by 4dacf5d (the two Linux ones were pre-existing flakiness that passed on rerun; textual-macOS was a genuine bug in my new test file — it was gated on toga.platform.current_platform (host OS) instead of toga.backend, so it incorrectly ran the Cocoa-only tests against the Textual backend when both share the same macOS host).

Testbed (macOS-arm64) and Testbed (macOS-x86_64) (Cocoa) both passed on the latest run aside from this one flaky retry.

@freakboy3742 freakboy3742 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some comments inline, mostly to do with minor formatting and verbosity issues; I've pushed an update to resolve those issues.

I've also worked out there's a much cleaner way to provide the method overrides, which I've implemented and documented.

Otherwise - this is great - thanks for the contribution.

Comment thread cocoa/src/toga_cocoa/app.py Outdated
Comment on lines +46 to +58
# ------------------------------------------------------------------
# App lifecycle
#
# These methods are thin wrappers that hand off to a same-named
# ``cocoa_``-prefixed method on the Cocoa App implementation class.
# This allows a user to override the platform-specific behavior of a
# lifecycle event, in the absence of a cross-platform API for the
# event, by replacing the ``cocoa_`` method on the ``toga_cocoa.App``
# *class* (not a specific app instance) with their own implementation.
# See the "Platform-specific APIs" section of the macOS docs for
# details, including why the override must be applied at the class
# level for normal Python method binding to work.
# ------------------------------------------------------------------

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excessive verbosity:

Suggested change
# ------------------------------------------------------------------
# App lifecycle
#
# These methods are thin wrappers that hand off to a same-named
# ``cocoa_``-prefixed method on the Cocoa App implementation class.
# This allows a user to override the platform-specific behavior of a
# lifecycle event, in the absence of a cross-platform API for the
# event, by replacing the ``cocoa_`` method on the ``toga_cocoa.App``
# *class* (not a specific app instance) with their own implementation.
# See the "Platform-specific APIs" section of the macOS docs for
# details, including why the override must be applied at the class
# level for normal Python method binding to work.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# App lifecycle
# ------------------------------------------------------------------

Comment thread changes/4478.feature.md
@@ -0,0 +1 @@
On macOS, the logic in the app delegate's native lifecycle notification methods has been moved to `cocoa_`-prefixed methods on the `toga_cocoa` App implementation class. Every native app lifecycle notification now has a corresponding method, most defaulting to a no-op, that can be overridden to add custom platform-specific behavior in the absence of a cross-platform lifecycle API.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is way too verbose for a release note.

Suggested change
On macOS, the logic in the app delegate's native lifecycle notification methods has been moved to `cocoa_`-prefixed methods on the `toga_cocoa` App implementation class. Every native app lifecycle notification now has a corresponding method, most defaulting to a no-op, that can be overridden to add custom platform-specific behavior in the absence of a cross-platform lifecycle API.
macOS apps now have a defined interface for responding to events in the Cocoa app lifecycle.

Comment thread cocoa/src/toga_cocoa/app.py Outdated
Comment on lines +350 to +361
#
# These methods back the AppDelegate's handling of native app lifecycle
# notifications. In the absence of a cross-platform API for these
# events, they exist as override points for app authors who need to
# react to a native lifecycle event; a method can be replaced by
# assigning a new function to the *class* (e.g.
# ``App.cocoa_applicationDidBecomeActive = my_function``). Assigning
# directly to an app's ``_impl`` instance instead won't bind ``self``,
# so the replacement callable would need to be created with that in
# mind (e.g., via ``types.MethodType``, or a function that doesn't
# take ``self``). See the "Platform-specific APIs" section of the
# macOS docs for usage details.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excessive verbosity:

Suggested change
#
# These methods back the AppDelegate's handling of native app lifecycle
# notifications. In the absence of a cross-platform API for these
# events, they exist as override points for app authors who need to
# react to a native lifecycle event; a method can be replaced by
# assigning a new function to the *class* (e.g.
# ``App.cocoa_applicationDidBecomeActive = my_function``). Assigning
# directly to an app's ``_impl`` instance instead won't bind ``self``,
# so the replacement callable would need to be created with that in
# mind (e.g., via ``types.MethodType``, or a function that doesn't
# take ``self``). See the "Platform-specific APIs" section of the
# macOS docs for usage details.

Comment thread testbed/tests/app/test_cocoa.py Outdated
# macOS can also run the Textual backend (e.g. the "textual-macOS" CI job), which
# doesn't have any of these cocoa_ methods.
####################################################################################
if toga.backend != "toga_cocoa":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see what you're trying to do here, but we don't put platform-specific tests in the testbed backend.

I'm not entirely convinced these tests are even needed... there wasn't a need for any no-cover exclusions in the existing implementation, so the same code should be covered here as well.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - turns out there is a coverage gap, but only for end-of-lifecycle events; we're generally OK adding no-cover for those.

@freakboy3742 freakboy3742 removed the basic checks failing Pull request does not pass basic quality checks label Jul 29, 2026
@freakboy3742
freakboy3742 merged commit 926dddc into beeware:main Jul 29, 2026
62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants