Skip to content

Add macOS packaging pipeline and fix Tk threading crash on macOS - #1

Open
quentinClaudel wants to merge 7 commits into
noebernigaud:mainfrom
quentinClaudel:feature/macos-packaging
Open

Add macOS packaging pipeline and fix Tk threading crash on macOS#1
quentinClaudel wants to merge 7 commits into
noebernigaud:mainfrom
quentinClaudel:feature/macos-packaging

Conversation

@quentinClaudel

@quentinClaudel quentinClaudel commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Adds a complete macOS release pipeline (signed .app bundle in a .dmg),
mirroring the existing Windows one, plus the source fixes needed to make the
packaged application actually work on macOS.

  • Bundles ExifTool for macOS (the pure-Perl Unix distribution, run through
    /usr/bin/perl) and resolves the ExifTool command per platform instead of
    hardcoding the Windows path.
  • Stores GUI settings in ~/Library/Application Support on macOS instead of
    falling through to the Linux ~/.config convention.
  • Reports startup failures on stderr unconditionally, and prints the full
    import traceback under --smoke-test, so a failing release build says why.
  • Fixes a crash (and a silent failure) caused by touching Tk from the analysis
    worker thread — see "Why the threading fix" below.
  • Adds packaging/macos/: build.sh (validate → package → sign → smoke test
    → disk image → checksums), a PyInstaller .spec producing a proper .app
    bundle, entitlements for the hardened runtime, notarize.sh, a
    check-deployment-target.py that measures the real minimum macOS version
    from the built binaries instead of trusting a configured one, and a
    runtime_hooks/cv2_loader.py fixing an OpenCV loader assumption that breaks
    inside a .app bundle's Resources/Frameworks split.
  • Documents all of the above in packaging/macos/README.md and links it from
    the root README, the dev guide, the Windows packaging guide, and the test
    guide.

Why the threading fix

The analysis worker thread called messagebox and Text.after() directly.
On Tk 8.6 this was silently forwarded to the main loop. Tcl 9 (bundled by
every portable macOS Python I could use) removed the tcl_platform(threaded)
flag that _tkinter used to decide whether to forward it, so the call now
runs inline on the calling thread instead:

  • A messagebox becomes an AppKit window created outside the main thread,
    and macOS aborts the process (this is the crash report that started this
    work).
  • after() from a worker thread registers its timer against a thread whose
    event queue nobody drains, so the callback silently never fire. The Logs
    panel stayed empty for the whole analysis, with no error at all.

gui/utils/main_loop.py adds a small dispatcher: worker threads post
callbacks to a queue, and only the main loop ever drains it. This works
regardless of Tcl version or platform, so it also removes a latent assumption
that happened to hold on Windows. Covered by tests/test_main_loop.py and
tests/test_gui_main_window.py.

Test plan

  • ./.venv/bin/python -m unittest discover -s tests -v on macOS
  • ./.venv/bin/python -m unittest discover -s tests -v on Windows
  • ./packaging/macos/build.sh end-to-end on Apple silicon
  • Install the produced .dmg on a clean Mac, run an analysis on a folder
    with videos, confirm the Logs panel updates live and the completion
    dialog appears
  • Confirm the Windows installer still builds and runs (this PR touches
    gui/main_window.py and gui/utils/logging.py, which are shared code)
  • Sign and notarize with a real Developer ID Application certificate,
    confirm no Gatekeeper warning on first launch with networking disabled

Only the Windows ExifTool was bundled, so GPS metadata operations had no
working backend on macOS.

macOS ships the ExifTool Unix distribution, which is pure Perl and runs on
the interpreter macOS provides at /usr/bin/perl. Keeping it pure Perl means
the application bundle gains no unsigned Mach-O binaries, which keeps code
signing and notarization simple. Other platforms fall back to an ExifTool
found on PATH.

packaging/macos/fetch-exiftool.sh stages the runtime files, verifies the
download against a pinned checksum, and confirms the staged copy reports the
expected version. It is kept on the same 13.36 release as the Windows copy so
that metadata behaves identically on both.

The Windows console-hiding test is restricted to Windows: STARTUPINFO and
CREATE_NO_WINDOW only exist in the Windows build of the standard library, so
the test failed outright anywhere else.
The macOS branch fell through to the XDG path and wrote settings.ini to
~/.config, which is a Linux convention. macOS keeps per-application state in
~/Library/Application Support.

Only affects developers who ran from source on macOS, since no macOS package
had been released; they start again from the defaults.
A startup failure was only ever shown in a dialog, and the fallback to stderr
ran only when the dialog itself could not be created. A packaged build that
failed to start therefore exited non-zero with no explanation, which is what a
release smoke test has to read.

Report on stderr first and always, and print the full traceback when running
--smoke-test, where the import chain is the diagnostic that matters.
Analysis runs on a worker thread, which reached the interface directly:
run_in_thread called messagebox, and TkinterLogHandler called
log_widget.after. Both crash or silently fail on macOS.

CPython's _tkinter forwards a cross-thread call to the main loop only when Tcl
describes itself as threaded through tcl_platform(threaded). Tcl 9 removed
that variable, because threads are always enabled, so _tkinter concludes the
interpreter is unthreaded and evaluates the call inline on the calling thread.
Measured with Tk 9.0.3:

- A dialog from a worker thread is an AppKit window created outside the main
  thread, and macOS terminates the process with an uncaught NSException.
- after() registers its timer against the worker thread, whose event queue
  nobody services, so the callback never runs. The log area stayed empty for
  the whole analysis and nothing reported it.

MainLoopDispatcher queues the work and drains it from a poll that only the
main loop ever schedules, which depends on neither the Tcl version nor the
platform. Tkinter is documented as not thread safe everywhere, so this also
removes the same latent assumption on Windows.

The run button is restored in a finally block as part of the same completion
path. It was disabled for the duration of a run and never re-enabled, so the
application could only ever perform one analysis per launch.
Mirrors packaging/windows: build.sh orchestrates dependency install, tests,
model validation, PyInstaller, code signing, smoke testing, disk image
assembly, and checksums.

CameraTrapAssistant.spec builds a signed .app bundle rather than a plain
directory. entitlements.plist enables the hardened runtime exceptions a
frozen Python application needs to load its own extension modules, which
notarization requires. notarize.sh submits an artifact to Apple and staples
the ticket.

check-deployment-target.py reads the Mach-O load commands across the whole
bundle and reports the actual minimum macOS version, then build.sh writes
that measured value into LSMinimumSystemVersion rather than trusting a
configured target: recent NumPy wheels for Apple silicon are built against
the macOS 14 SDK regardless of which interpreter builds the bundle, so a
declared-but-unverified minimum would let the release claim support it
cannot deliver.

runtime_hooks/cv2_loader.py works around an OpenCV loader assumption that
does not hold inside a macOS .app: PyInstaller splits data into
Contents/Resources and binaries into Contents/Frameworks, which defeats the
loader's own-location check and makes it re-import itself instead of the
native extension.

make-dmg.sh assembles the disk image users download: the application next to
an alias of /Applications, with the window layout and background macOS users
expect from a drag-to-install package. make-icons.py draws the application
icon, volume icon, and disk image background from code so the artwork can
never drift from the packaging scripts.
Adds macOS install instructions to the user-facing README alongside the
existing Windows ones, and points the developer guide, the Windows packaging
guide, and the test guide at packaging/macos/README.md.

Also notes in the test guide that platform-specific behavior, such as hiding
the ExifTool console window on Windows, must be guarded with
unittest.skipUnless rather than left to fail on other platforms, since the
suite now has to pass on every platform with a packaging pipeline.
No Intel Mac is available to build or test an x86_64 release, so documenting
it as supported would be a claim this project cannot back up. Restrict the
install instructions and the packaging guide to Apple silicon.
super().__init__()
self.log_widget = log_widget
self.on_unread_change = on_unread_change
# Analysis runs on a worker thread, so records arrive off the main

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A note on the verbose comments throughout this PR: feel free to trim them if they feel excessive.

I chose to keep them fairly detailed on purpose. As this is an open-source project, and several of the fixes here rely on non-obvious platform behavior that isn't discoverable by reading the code alone. Spelling out the why inline should make it easier for contributors who aren't familiar with this part of the codebase, or with macOS packaging in general, to understand a change without having to dig through git history or external references.

Can cut any of them down if you'd rather keep things terser.

@noebernigaud

Copy link
Copy Markdown
Owner

The command for windows tests is

.\.venv\Scripts\python.exe -m unittest discover -s tests -v

not

./.venv/bin/python -m unittest discover -s tests -v

@noebernigaud

noebernigaud commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Tests on Windows have one fail:

FAIL: test_macos_command_runs_bundled_script_through_perl (test_exiftool_interface.ExifToolCommandTests.test_macos_command_runs_bundled_script_through_perl)

Seems like this test should not be runned on Windows.

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