Add macOS packaging pipeline and fix Tk threading crash on macOS - #1
Add macOS packaging pipeline and fix Tk threading crash on macOS#1quentinClaudel wants to merge 7 commits into
Conversation
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 |
There was a problem hiding this comment.
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.
|
The command for windows tests is
not
|
|
Tests on Windows have one fail:
Seems like this test should not be runned on Windows. |
Summary
Adds a complete macOS release pipeline (signed
.appbundle in a.dmg),mirroring the existing Windows one, plus the source fixes needed to make the
packaged application actually work on macOS.
/usr/bin/perl) and resolves the ExifTool command per platform instead ofhardcoding the Windows path.
~/Library/Application Supporton macOS instead offalling through to the Linux
~/.configconvention.import traceback under
--smoke-test, so a failing release build says why.worker thread — see "Why the threading fix" below.
packaging/macos/:build.sh(validate → package → sign → smoke test→ disk image → checksums), a PyInstaller
.specproducing a proper.appbundle, entitlements for the hardened runtime,
notarize.sh, acheck-deployment-target.pythat measures the real minimum macOS versionfrom the built binaries instead of trusting a configured one, and a
runtime_hooks/cv2_loader.pyfixing an OpenCV loader assumption that breaksinside a
.appbundle's Resources/Frameworks split.packaging/macos/README.mdand links it fromthe root README, the dev guide, the Windows packaging guide, and the test
guide.
Why the threading fix
The analysis worker thread called
messageboxandText.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
_tkinterused to decide whether to forward it, so the call nowruns inline on the calling thread instead:
messageboxbecomes 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 whoseevent 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.pyadds a small dispatcher: worker threads postcallbacks 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.pyandtests/test_gui_main_window.py.Test plan
./.venv/bin/python -m unittest discover -s tests -von macOS./.venv/bin/python -m unittest discover -s tests -von Windows./packaging/macos/build.shend-to-end on Apple silicon.dmgon a clean Mac, run an analysis on a folderwith videos, confirm the Logs panel updates live and the completion
dialog appears
gui/main_window.pyandgui/utils/logging.py, which are shared code)confirm no Gatekeeper warning on first launch with networking disabled