Skip to content

Commit 867dd8d

Browse files
authored
Merge pull request #957 from eoyilmaz/956-startup-does-not-prompt-to-download-locate-argyllcms-when-missing
[#956] Fix ArgyllCMS missing prompt not firing on silent startup for wx and Qt
2 parents 782f185 + 92028c5 commit 867dd8d

7 files changed

Lines changed: 589 additions & 47 deletions

File tree

DisplayCAL/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10.0.dev78
1+
3.10.0.dev79

DisplayCAL/display_cal.py

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -406,12 +406,13 @@ def get_download_url(newversion: str) -> str | None:
406406
return None
407407

408408

409-
def is_new_update() -> bool | tuple:
409+
def is_new_update() -> bool | tuple | None:
410410
"""Check for new updates on GitHub.
411411

412412
Returns:
413-
bool | tuple: The latest version tuple if a new update is available,
414-
False otherwise.
413+
bool | tuple | None: The latest version tuple if a new update is
414+
available, False if already up to date, None if the check failed
415+
(network or parsing error).
415416
"""
416417
global RELEASE_DATA
417418
print("Checking for updates...")
@@ -423,15 +424,15 @@ def is_new_update() -> bool | tuple:
423424
response.raise_for_status()
424425
except requests.RequestException as e:
425426
print(f"Error checking for updates: Network error - {e!s}")
426-
return False
427+
return None
427428

428429
try:
429430
data = response.json()
430431
RELEASE_DATA = data
431432
latest_version_tuple = tuple(int(n) for n in data["tag_name"].split("."))
432433
except (KeyError, ValueError, IndexError) as e:
433434
print(f"Error checking for updates: Parsing error - {e!s}")
434-
return False
435+
return None
435436

436437
current_version = VERSION_TUPLE[:3]
437438
if latest_version_tuple > current_version:
@@ -500,45 +501,41 @@ def app_update_check(
500501
silent=silent,
501502
)
502503
if resp is False:
503-
if silent:
504-
# Check if we need to run instrument setup
505-
wx.CallAfter(
506-
parent.check_instrument_setup, check_donation, (parent, snapshot)
507-
)
508-
return
509-
data = resp.read()
510-
if not wx.GetApp():
511-
return
512-
try:
513-
new_version_tuple = tuple(int(n) for n in data.decode().split("."))
514-
except ValueError:
515-
print(lang.getstr("update_check.fail.version", DOMAIN))
516-
if not silent:
517-
wx.CallAfter(
518-
InfoDialog,
519-
parent,
520-
msg=lang.getstr("update_check.fail.version", DOMAIN),
521-
ok=lang.getstr("ok"),
522-
bitmap=get_icon(32, "dialog-error"),
523-
log=False,
524-
)
504+
# Fetch failed: treat as "no update" and fall through to the
505+
# "up to date" branches below (which also check ArgyllCMS and
506+
# instrument setup), regardless of silent/non-silent.
507+
new_version_tuple = curversion_tuple
508+
else:
509+
data = resp.read()
510+
if not wx.GetApp():
525511
return
526-
new_version_tuple = (0, 0, 0, 0)
512+
try:
513+
new_version_tuple = tuple(int(n) for n in data.decode().split("."))
514+
except ValueError:
515+
print(lang.getstr("update_check.fail.version", DOMAIN))
516+
if not silent:
517+
wx.CallAfter(
518+
InfoDialog,
519+
parent,
520+
msg=lang.getstr("update_check.fail.version", DOMAIN),
521+
ok=lang.getstr("ok"),
522+
bitmap=get_icon(32, "dialog-error"),
523+
log=False,
524+
)
525+
return
526+
new_version_tuple = (0, 0, 0, 0)
527527
else:
528528
# Stable
529529
print(lang.getstr("update_check"))
530530
curversion_tuple = VERSION_TUPLE
531531
chglog_file = "CHANGES.html"
532532
resp = is_new_update()
533-
if resp is False:
534-
if silent:
535-
# Check if we need to run instrument setup
536-
wx.CallAfter(
537-
parent.check_instrument_setup, check_donation, (parent, snapshot)
538-
)
539-
return
540-
# Non-silent with no update available: fall through to the
541-
# "up to date" branches below using the current version.
533+
if resp is False or resp is None:
534+
# No update available, or the check itself failed (network/parse
535+
# error): either way fall through to the "up to date" branches
536+
# below using the current version. This also applies during a
537+
# silent startup check, since those branches are what eventually
538+
# reach the ArgyllCMS / instrument-setup checks further down.
542539
resp = curversion_tuple
543540
if not wx.GetApp():
544541
return

DisplayCAL/ui/main_window.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@
177177
check_argyll_bin,
178178
check_set_argyll_bin,
179179
get_argyll_instrument_config,
180+
get_argyll_latest_version,
180181
get_argyll_util,
182+
get_homebrew_argyll_bin,
181183
make_argyll_compatible_path,
182184
)
183185
from DisplayCAL.argyll_instruments import get_canonical_instrument_name
@@ -282,6 +284,7 @@
282284
UntetheredController,
283285
WorkerRunController,
284286
)
287+
from DisplayCAL.update_check import resolve_argyll_download_url
285288
from DisplayCAL.util_decimal import stripzeros
286289
from DisplayCAL.util_dict import dict_sort
287290
from DisplayCAL.util_os import get_program_file, launch_file, waccess, which
@@ -502,6 +505,52 @@ def run(self) -> None: # noqa: D102 (QThread override)
502505
self.done.emit(result)
503506

504507

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+
505554
#: Sentinel returned by :meth:`MainWindow._current_cal_choice` when the user
506555
#: cancels, distinguishable from its other possible results (``None``,
507556
#: ``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:
13101359
#: as opposed to :attr:`_install_profile_window`'s standalone flow.
13111360
self._profile_install_thread: _ProfileInstallThread | None = None
13121361
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
13131366
#: Services :meth:`Worker.authenticate`'s sudo password prompt for any
13141367
#: elevated (local-system/network) install scope chosen in that dialog.
13151368
self.worker.password_prompt = PasswordPromptAdapter(parent=self)
@@ -1879,6 +1932,93 @@ def _select_install_profile_action_handler(self) -> None:
18791932
self._install_profile_window.raise_()
18801933
self._install_profile_window.activateWindow()
18811934

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+
18822022
def _set_argyll_bin_handler(self) -> None:
18832023
"""File menu "Locate ArgyllCMS executables..." handler.
18842024
@@ -3012,6 +3152,8 @@ def run_post_launch_checks(self) -> None:
30123152

30133153
def _run_instrument_setup_and_donation_check(self) -> None:
30143154
"""Qt port of ``MainFrame.check_instrument_setup``'s dispatch."""
3155+
if not check_argyll_bin():
3156+
self._prompt_missing_argyll()
30153157
needs = instrument_setup.resolve_instrument_setup_needs(
30163158
self.worker, self._ccmx_catalog.instruments.values()
30173159
)
@@ -3597,6 +3739,13 @@ def _build_display_instrument_tab(self) -> QWidget:
35973739
display_row = QHBoxLayout()
35983740
display_form = QFormLayout()
35993741
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)
36003749
self.display_ctrl.currentIndexChanged.connect(self.display_ctrl_handler)
36013750
# No row label: the group box is already titled "Display" right
36023751
# above this combo, so a per-row "Display" label would just repeat it.
@@ -3654,6 +3803,9 @@ def _build_display_instrument_tab(self) -> QWidget:
36543803
instrument_outer = QVBoxLayout(instrument_box)
36553804
instrument_form = QFormLayout()
36563805
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)
36573809
self.comport_ctrl.currentIndexChanged.connect(self.comport_ctrl_handler)
36583810
self.measurement_mode_ctrl = QComboBox()
36593811
self.measurement_mode_ctrl.currentIndexChanged.connect(

DisplayCAL/update_check.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,16 @@
2626
already);
2727
* the in-app auto-download-and-run-the-installer flow
2828
(``app_update_confirm``'s ``worker.start(consumer, worker.download, ...)``
29-
branch) — the Qt dialog offers a direct asset download URL (opened in the
30-
browser) or a "go to website" fallback instead of driving Argyll/DisplayCAL
31-
installers itself, which is a large, separate feature in its own right.
29+
branch) for the *update-available* dialogs — the Qt
30+
``_UpdateAvailableDialog`` offers a direct asset download URL (opened in
31+
the browser) or a "go to website" fallback instead of driving
32+
Argyll/DisplayCAL installers itself. The *missing*-ArgyllCMS startup
33+
prompt (``MainWindow._prompt_missing_argyll``) is a separate flow and
34+
does drive a real in-app download + extract, via
35+
:func:`resolve_argyll_download_url` below plus a small Qt-only
36+
``_ArgyllDownloadThread`` (``main_window.py``) — installing ArgyllCMS in
37+
the first place is table-stakes for a working app, unlike an optional
38+
version bump.
3239
"""
3340

3441
from __future__ import annotations
@@ -120,6 +127,41 @@ def resolve_app_download_url(release_data: dict, newversion: str) -> str | None:
120127
return None
121128

122129

130+
def resolve_argyll_download_url(newversion: str, domain: str) -> str:
131+
"""Return the ArgyllCMS release archive URL for the current platform.
132+
133+
Toolkit-neutral port of the ArgyllCMS branch of wx's
134+
``app_update_confirm`` (``display_cal.py``): same
135+
``argyll.domain``-relative GitHub Releases layout and per-platform
136+
suffix table, simplified to ``platform.machine()`` detection (no
137+
Windows registry lookup) like :func:`resolve_app_download_url`.
138+
Confirmed against the real ``eoyilmaz/argyllcms-binaries`` release
139+
assets, which are named exactly ``Argyll_V{version}{suffix}``.
140+
141+
Args:
142+
newversion: The ArgyllCMS version string (e.g. ``"3.5.0"``).
143+
domain: The ``argyll.domain`` config value (a GitHub repo URL).
144+
"""
145+
machine = platform.machine().lower()
146+
if sys.platform == "win32":
147+
if machine in ("arm64", "aarch64"):
148+
suffix = "_win_arm64_exe.zip"
149+
elif machine in ("amd64", "x86_64"):
150+
suffix = "_win64_exe.zip"
151+
else:
152+
suffix = "_win32_exe.zip"
153+
elif sys.platform == "darwin":
154+
if machine in ("arm64", "aarch64"):
155+
suffix = "_macOS11_arm64_bin.tgz"
156+
else:
157+
suffix = "_osx10.6_x86_64_bin.tgz"
158+
elif machine in ("x86_64", "amd64") or platform.architecture()[0] == "64bit":
159+
suffix = "_linux_x86_64_bin.tgz"
160+
else:
161+
suffix = "_linux_x86_bin.tgz"
162+
return f"{domain}/releases/download/{newversion}/Argyll_V{newversion}{suffix}"
163+
164+
123165
def _format_changelog(html: str, domain: str) -> str:
124166
"""Rewrite anchor-only ``href``s and demote heading tags, matching wx."""
125167
html = re.sub(

0 commit comments

Comments
 (0)