|
177 | 177 | check_argyll_bin, |
178 | 178 | check_set_argyll_bin, |
179 | 179 | get_argyll_instrument_config, |
| 180 | + get_argyll_latest_version, |
180 | 181 | get_argyll_util, |
| 182 | + get_homebrew_argyll_bin, |
181 | 183 | make_argyll_compatible_path, |
182 | 184 | ) |
183 | 185 | from DisplayCAL.argyll_instruments import get_canonical_instrument_name |
|
282 | 284 | UntetheredController, |
283 | 285 | WorkerRunController, |
284 | 286 | ) |
| 287 | +from DisplayCAL.update_check import resolve_argyll_download_url |
285 | 288 | from DisplayCAL.util_decimal import stripzeros |
286 | 289 | from DisplayCAL.util_dict import dict_sort |
287 | 290 | from DisplayCAL.util_os import get_program_file, launch_file, waccess, which |
@@ -502,6 +505,52 @@ def run(self) -> None: # noqa: D102 (QThread override) |
502 | 505 | self.done.emit(result) |
503 | 506 |
|
504 | 507 |
|
| 508 | +class _ArgyllDownloadThread(QThread): |
| 509 | + """Download and extract an ArgyllCMS release archive off the GUI thread. |
| 510 | +
|
| 511 | + Qt port of wx's ``Worker.process_argyll_download`` / |
| 512 | + ``Worker.extract_archive`` pair (``worker.py``), combined into one |
| 513 | + thread run behind a single progress dialog instead of wx's two chained |
| 514 | + ``worker.start()`` calls (download, then extract). |
| 515 | + """ |
| 516 | + |
| 517 | + #: Emitted with the list of extracted paths, or an ``Exception``. |
| 518 | + done = Signal(object) |
| 519 | + |
| 520 | + def __init__(self, worker: Worker, url: str, parent: QWidget | None = None) -> None: |
| 521 | + super().__init__(parent) |
| 522 | + self._worker = worker |
| 523 | + self._url = url |
| 524 | + |
| 525 | + def run(self) -> None: # noqa: D102 (QThread override) |
| 526 | + result = self._worker.download(self._url) |
| 527 | + if isinstance(result, Exception): |
| 528 | + self.done.emit(result) |
| 529 | + return |
| 530 | + if not result or not ( |
| 531 | + result.lower().endswith(".zip") or result.lower().endswith(".tgz") |
| 532 | + ): |
| 533 | + self.done.emit( |
| 534 | + Exception(f"{lang.getstr('error.file_type_unsupported')}\n{result}") |
| 535 | + ) |
| 536 | + return |
| 537 | + try: |
| 538 | + extracted = self._worker.extract_archive(result) |
| 539 | + except Exception as exception: # noqa: BLE001 (reported on GUI thread) |
| 540 | + self.done.emit(exception) |
| 541 | + return |
| 542 | + if ( |
| 543 | + isinstance(extracted, Exception) |
| 544 | + or not extracted |
| 545 | + or not os.path.isdir(extracted[0]) |
| 546 | + ): |
| 547 | + self.done.emit( |
| 548 | + Exception(lang.getstr("error.no_files_extracted_from_archive", result)) |
| 549 | + ) |
| 550 | + return |
| 551 | + self.done.emit(extracted) |
| 552 | + |
| 553 | + |
505 | 554 | #: Sentinel returned by :meth:`MainWindow._current_cal_choice` when the user |
506 | 555 | #: cancels, distinguishable from its other possible results (``None``, |
507 | 556 | #: ``False``, or a ``.cal`` path) -- the Qt stand-in for wx's ``wx.ID_CANCEL``. |
@@ -1310,6 +1359,10 @@ def __init__(self, worker: Worker | None = None) -> None: |
1310 | 1359 | #: as opposed to :attr:`_install_profile_window`'s standalone flow. |
1311 | 1360 | self._profile_install_thread: _ProfileInstallThread | None = None |
1312 | 1361 | self._profile_install_progress: QProgressDialog | None = None |
| 1362 | + #: Background ArgyllCMS download+extract driven by the missing-Argyll |
| 1363 | + #: startup prompt (:meth:`_prompt_missing_argyll`). |
| 1364 | + self._argyll_download_thread: _ArgyllDownloadThread | None = None |
| 1365 | + self._argyll_download_progress: QProgressDialog | None = None |
1313 | 1366 | #: Services :meth:`Worker.authenticate`'s sudo password prompt for any |
1314 | 1367 | #: elevated (local-system/network) install scope chosen in that dialog. |
1315 | 1368 | self.worker.password_prompt = PasswordPromptAdapter(parent=self) |
@@ -1879,6 +1932,93 @@ def _select_install_profile_action_handler(self) -> None: |
1879 | 1932 | self._install_profile_window.raise_() |
1880 | 1933 | self._install_profile_window.activateWindow() |
1881 | 1934 |
|
| 1935 | + def _prompt_missing_argyll(self) -> None: |
| 1936 | + """Startup prompt for missing ArgyllCMS binaries. |
| 1937 | +
|
| 1938 | + Qt port of the ``wx.SingleChoiceDialog`` half of wx's |
| 1939 | + ``argyll.set_argyll_bin()``, shown from |
| 1940 | + ``_run_instrument_setup_and_donation_check`` when |
| 1941 | + ``check_argyll_bin()`` fails. "Download" drives a real in-app |
| 1942 | + download + extract (see :meth:`_download_and_install_argyll`), |
| 1943 | + matching wx; cancelling just dismisses the prompt (Argyll stays |
| 1944 | + unconfigured until the user acts again, matching wx's own cancel |
| 1945 | + behaviour). |
| 1946 | + """ |
| 1947 | + box = QMessageBox(self) |
| 1948 | + box.setWindowTitle(APPNAME) |
| 1949 | + box.setIcon(QMessageBox.Warning) |
| 1950 | + box.setText(lang.getstr("dialog.argyll.notfound.choice")) |
| 1951 | + download_button = box.addButton( |
| 1952 | + lang.getstr("download"), QMessageBox.AcceptRole |
| 1953 | + ) |
| 1954 | + browse_button = box.addButton(lang.getstr("browse"), QMessageBox.ActionRole) |
| 1955 | + brew_argyll_bin = get_homebrew_argyll_bin() |
| 1956 | + homebrew_button = None |
| 1957 | + if brew_argyll_bin: |
| 1958 | + homebrew_button = box.addButton( |
| 1959 | + lang.getstr("argyll.use_homebrew", brew_argyll_bin), |
| 1960 | + QMessageBox.ActionRole, |
| 1961 | + ) |
| 1962 | + box.addButton(lang.getstr("cancel"), QMessageBox.RejectRole) |
| 1963 | + message_box.exec_box(box) |
| 1964 | + clicked = box.clickedButton() |
| 1965 | + if clicked is download_button: |
| 1966 | + self._download_and_install_argyll() |
| 1967 | + elif clicked is browse_button: |
| 1968 | + self._set_argyll_bin_handler() |
| 1969 | + elif homebrew_button is not None and clicked is homebrew_button: |
| 1970 | + setcfg("argyll.dir", brew_argyll_bin) |
| 1971 | + writecfg() |
| 1972 | + |
| 1973 | + def _download_and_install_argyll(self) -> None: |
| 1974 | + """Download the latest ArgyllCMS release and configure ``argyll.dir``. |
| 1975 | +
|
| 1976 | + Qt port of wx's ``app_update_confirm`` ArgyllCMS-download branch |
| 1977 | + plus ``Worker.process_argyll_download``/``set_argyll_bin``: resolves |
| 1978 | + the platform-specific release archive URL, downloads and extracts it |
| 1979 | + on a background thread behind an indeterminate progress dialog (the |
| 1980 | + same pattern as :meth:`_install_profile_direct`), then points |
| 1981 | + ``argyll.dir`` at the extracted ``bin`` folder. Falls back to an |
| 1982 | + error dialog with a "go to website" style message on failure -- |
| 1983 | + Argyll stays unconfigured, same as a cancelled/failed wx download. |
| 1984 | + """ |
| 1985 | + newversion = get_argyll_latest_version() |
| 1986 | + url = resolve_argyll_download_url(newversion, getcfg("argyll.domain")) |
| 1987 | + self._argyll_download_progress = QProgressDialog( |
| 1988 | + lang.getstr("downloading"), "", 0, 0, self |
| 1989 | + ) |
| 1990 | + self._argyll_download_progress.setWindowTitle(APPNAME) |
| 1991 | + self._argyll_download_progress.setCancelButton(None) |
| 1992 | + self._argyll_download_progress.show() |
| 1993 | + self._argyll_download_thread = _ArgyllDownloadThread( |
| 1994 | + self.worker, url, parent=self |
| 1995 | + ) |
| 1996 | + self._argyll_download_thread.done.connect(self._on_argyll_download_done) |
| 1997 | + self._argyll_download_thread.start() |
| 1998 | + |
| 1999 | + def _on_argyll_download_done(self, result: object) -> None: |
| 2000 | + """Handle the background ArgyllCMS download+extract result. |
| 2001 | +
|
| 2002 | + Args: |
| 2003 | + result (object): The list of extracted paths, or an |
| 2004 | + ``Exception`` on failure. |
| 2005 | + """ |
| 2006 | + self._argyll_download_thread = None |
| 2007 | + if self._argyll_download_progress is not None: |
| 2008 | + self._argyll_download_progress.close() |
| 2009 | + self._argyll_download_progress = None |
| 2010 | + if isinstance(result, Exception): |
| 2011 | + message_box.critical(self, APPNAME, str(result)) |
| 2012 | + return |
| 2013 | + setcfg("argyll.dir", os.path.join(result[0], "bin")) |
| 2014 | + writecfg() |
| 2015 | + # Qt port of wx's own post-download behaviour: ``set_argyll_bin_handler`` |
| 2016 | + # (``display_cal.py``) calls ``check_update_controls`` once Argyll |
| 2017 | + # becomes available, which re-enumerates displays/instruments rather |
| 2018 | + # than leaving the user to notice and click "Detect display devices |
| 2019 | + # and instruments" themselves. |
| 2020 | + self.detect_displays_and_ports_btn_handler() |
| 2021 | + |
1882 | 2022 | def _set_argyll_bin_handler(self) -> None: |
1883 | 2023 | """File menu "Locate ArgyllCMS executables..." handler. |
1884 | 2024 |
|
@@ -3012,6 +3152,8 @@ def run_post_launch_checks(self) -> None: |
3012 | 3152 |
|
3013 | 3153 | def _run_instrument_setup_and_donation_check(self) -> None: |
3014 | 3154 | """Qt port of ``MainFrame.check_instrument_setup``'s dispatch.""" |
| 3155 | + if not check_argyll_bin(): |
| 3156 | + self._prompt_missing_argyll() |
3015 | 3157 | needs = instrument_setup.resolve_instrument_setup_needs( |
3016 | 3158 | self.worker, self._ccmx_catalog.instruments.values() |
3017 | 3159 | ) |
@@ -3597,6 +3739,13 @@ def _build_display_instrument_tab(self) -> QWidget: |
3597 | 3739 | display_row = QHBoxLayout() |
3598 | 3740 | display_form = QFormLayout() |
3599 | 3741 | self.display_ctrl = QComboBox() |
| 3742 | + # Qt's default AdjustToContentsOnFirstShow policy measures the combo |
| 3743 | + # once, while it's still empty (displays are only populated later by |
| 3744 | + # detect_displays_and_ports_btn_handler()), and never re-measures -- |
| 3745 | + # leaving it stuck too narrow for the real display names. Recompute |
| 3746 | + # on every content change instead, matching wx's Choice/ComboBox, |
| 3747 | + # which auto-sizes to its current items without extra plumbing. |
| 3748 | + self.display_ctrl.setSizeAdjustPolicy(QComboBox.AdjustToContents) |
3600 | 3749 | self.display_ctrl.currentIndexChanged.connect(self.display_ctrl_handler) |
3601 | 3750 | # No row label: the group box is already titled "Display" right |
3602 | 3751 | # above this combo, so a per-row "Display" label would just repeat it. |
@@ -3654,6 +3803,9 @@ def _build_display_instrument_tab(self) -> QWidget: |
3654 | 3803 | instrument_outer = QVBoxLayout(instrument_box) |
3655 | 3804 | instrument_form = QFormLayout() |
3656 | 3805 | self.comport_ctrl = QComboBox() |
| 3806 | + # Same AdjustToContents fix as display_ctrl above -- instruments are |
| 3807 | + # also only populated after the initial (empty) first show. |
| 3808 | + self.comport_ctrl.setSizeAdjustPolicy(QComboBox.AdjustToContents) |
3657 | 3809 | self.comport_ctrl.currentIndexChanged.connect(self.comport_ctrl_handler) |
3658 | 3810 | self.measurement_mode_ctrl = QComboBox() |
3659 | 3811 | self.measurement_mode_ctrl.currentIndexChanged.connect( |
|
0 commit comments