-
Notifications
You must be signed in to change notification settings - Fork 18
feat: Notify customers if newer submitter is available. #274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
karthikbekalp
wants to merge
5
commits into
aws-deadline:mainline
Choose a base branch
from
karthikbekalp:updateDialogp
base: mainline
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
88b0bfb
feat: Notify customers if newer submitter is available.
karthikbekalp a661875
chore: Add setting to deactivate notifications.
karthikbekalp aa14afb
chore: Responded to comments from Joel W
karthikbekalp ad4798f
chore: Modify second dialog to redirect to the setup instructions.
karthikbekalp b9f7530
Merge branch 'mainline' into updateDialogp
Cherie-Chen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
|
|
||
| """ | ||
| Update checker for the Deadline Cloud for Unreal Engine plugin. | ||
|
|
||
| Checks the GitHub releases API for a newer version and shows an Unreal | ||
| Editor dialog when an update is available. Respects the Deadline Cloud | ||
| ``settings.submitter_update_notification`` config toggle. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import logging | ||
| import os | ||
| import socket | ||
| import ssl | ||
| import urllib.request | ||
| import urllib.error | ||
| import webbrowser | ||
|
|
||
| import botocore | ||
| import unreal | ||
|
|
||
| from packaging.version import Version, InvalidVersion | ||
|
|
||
| from deadline.client.config import config_file | ||
| from deadline.unreal_submitter._version import version as _current_version | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| GITHUB_LATEST_RELEASE_URL = ( | ||
| "https://api.github.com/repos/aws-deadline/deadline-cloud-for-unreal-engine/releases/latest" | ||
| ) | ||
| RELEASES_PAGE_URL = "https://github.com/aws-deadline/deadline-cloud-for-unreal-engine/releases" | ||
| _REQUEST_TIMEOUT_SECONDS = 5 | ||
|
|
||
|
|
||
| def _is_update_notification_enabled() -> bool: | ||
| """Check whether the user has opted in to update notifications.""" | ||
| return config_file.str2bool(config_file.get_setting("settings.submitter_update_notification")) | ||
|
|
||
|
|
||
| def _get_current_version() -> str: | ||
| """Return the currently installed plugin version string.""" | ||
| return _current_version | ||
|
|
||
|
|
||
| def _fetch_latest_version() -> str | None: | ||
| """Fetch the latest release tag from GitHub. | ||
|
|
||
| Returns: | ||
| The version string (e.g. ``"0.6.5"``) or ``None`` on failure. | ||
| """ | ||
| # Pin to GitHub REST API v3 JSON format so the response shape stays stable. | ||
| req = urllib.request.Request( | ||
| GITHUB_LATEST_RELEASE_URL, | ||
| headers={"Accept": "application/vnd.github.v3+json"}, | ||
| ) | ||
|
|
||
| # Build a strict TLS context: enforce TLS 1.2+ and use the botocore CA | ||
| # bundle so certificate verification works even in embedded Python | ||
| # environments (e.g. Unreal) that may lack system root certificates. | ||
| ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) | ||
| ctx.minimum_version = ssl.TLSVersion.TLSv1_2 | ||
| ctx.load_verify_locations(_get_botocore_ca_bundle()) | ||
|
|
||
| try: | ||
| with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_SECONDS, context=ctx) as resp: | ||
| data = json.loads(resp.read().decode("utf-8")) | ||
| tag = data.get("tag_name", "") | ||
| return tag.lstrip("v") if tag else None | ||
| except urllib.error.URLError: | ||
| return None | ||
| except (socket.timeout, TimeoutError): | ||
| return None | ||
| except json.JSONDecodeError: | ||
| return None | ||
|
|
||
|
|
||
| def _get_botocore_ca_bundle() -> str: | ||
| """Return the path to botocore's bundled CA certificate bundle.""" | ||
| return os.path.join(os.path.dirname(botocore.__file__), "cacert.pem") | ||
|
|
||
|
|
||
| def _is_update_available(current: str, latest: str) -> bool: | ||
| """Return True if *latest* is strictly newer than *current*.""" | ||
| try: | ||
| return Version(latest) > Version(current) | ||
| except InvalidVersion: | ||
| return False | ||
|
|
||
|
|
||
| def safe_check_and_show_update_dialog() -> bool: | ||
| """Check GitHub for a newer release and show an Unreal dialog if found. | ||
|
|
||
| Returns: | ||
| ``True`` if the user chose to open the download page (caller may | ||
| want to skip opening the submitter), ``False`` otherwise. | ||
| """ | ||
| try: | ||
| return _check_and_show_update_dialog() | ||
| except Exception: | ||
| logger.debug("Update check failed -- skipping", exc_info=True) | ||
| return False | ||
|
|
||
|
|
||
| def _check_and_show_update_dialog() -> bool: | ||
| """Internal implementation of the update check and dialog flow.""" | ||
| if not _is_update_notification_enabled(): | ||
| return False | ||
|
|
||
| current_version = _get_current_version() | ||
|
|
||
| latest_version = _fetch_latest_version() | ||
|
|
||
| if not latest_version: | ||
| return False | ||
|
|
||
| if not _is_update_available(current_version, latest_version): | ||
| return False | ||
|
|
||
| message = ( | ||
| f"Version {latest_version} of Deadline Cloud for Unreal Engine " | ||
| f"submitter is now available.\n\n" | ||
| f"Current: {current_version} -> New: {latest_version}\n\n" | ||
| f"View release notes:\n{RELEASES_PAGE_URL}\n\n" | ||
| "Click 'Yes' to be redirected to the release page, or 'No' to dismiss." | ||
| ) | ||
|
|
||
| response = unreal.EditorDialog.show_message( | ||
| "New version available", | ||
| message, | ||
| unreal.AppMsgType.YES_NO, | ||
| ) | ||
|
|
||
| if response == unreal.AppReturnType.YES: | ||
| try: | ||
| webbrowser.open(RELEASES_PAGE_URL) | ||
joel-wong-aws marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| except Exception: | ||
| return False | ||
|
|
||
| unreal.EditorDialog.show_message( | ||
| "Application Restart Required", | ||
| "Please install the new release and then restart Unreal Engine " | ||
| "to use the new version.", | ||
| unreal.AppMsgType.OK, | ||
| ) | ||
| return True | ||
|
|
||
| return False | ||
180 changes: 180 additions & 0 deletions
180
test/deadline_submitter_for_unreal/unit/test_update_check.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import sys | ||
| import urllib.error | ||
| from unittest.mock import patch, MagicMock | ||
|
|
||
| # Mock the 'unreal' module before importing update_check, since it's only | ||
| # available inside the Unreal Engine Python environment. | ||
| _mock_unreal = MagicMock() | ||
| sys.modules["unreal"] = _mock_unreal | ||
|
|
||
|
|
||
| # Now safe to import — unreal is already in sys.modules. | ||
| from update_check import ( # noqa: E402 | ||
| _fetch_latest_version, | ||
| _is_update_available, | ||
| safe_check_and_show_update_dialog, | ||
| RELEASES_PAGE_URL, | ||
| ) | ||
|
|
||
|
|
||
| class TestFetchLatestVersion: | ||
| """Tests for _fetch_latest_version().""" | ||
|
|
||
| @patch("update_check.ssl.create_default_context") | ||
| @patch("update_check.urllib.request.urlopen") | ||
| @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") | ||
| def test_returns_version_from_github(self, mock_ca, mock_urlopen, mock_ssl): | ||
| response_data = json.dumps({"tag_name": "v0.6.5"}).encode("utf-8") | ||
| mock_resp = MagicMock() | ||
| mock_resp.read.return_value = response_data | ||
| mock_resp.__enter__ = MagicMock(return_value=mock_resp) | ||
| mock_resp.__exit__ = MagicMock(return_value=False) | ||
| mock_urlopen.return_value = mock_resp | ||
|
|
||
| assert _fetch_latest_version() == "0.6.5" | ||
|
|
||
| @patch("update_check.ssl.create_default_context") | ||
| @patch("update_check.urllib.request.urlopen") | ||
| @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") | ||
| def test_returns_none_on_network_error(self, mock_ca, mock_urlopen, mock_ssl): | ||
| mock_urlopen.side_effect = urllib.error.URLError("connection refused") | ||
|
|
||
| assert _fetch_latest_version() is None | ||
|
|
||
| @patch("update_check.ssl.create_default_context") | ||
| @patch("update_check.urllib.request.urlopen") | ||
| @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") | ||
| def test_returns_none_on_timeout(self, mock_ca, mock_urlopen, mock_ssl): | ||
| import socket | ||
|
|
||
| mock_urlopen.side_effect = socket.timeout("timed out") | ||
|
|
||
| assert _fetch_latest_version() is None | ||
|
|
||
| @patch("update_check.ssl.create_default_context") | ||
| @patch("update_check.urllib.request.urlopen") | ||
| @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") | ||
| def test_returns_none_on_invalid_json(self, mock_ca, mock_urlopen, mock_ssl): | ||
| mock_resp = MagicMock() | ||
| mock_resp.read.return_value = b"not json" | ||
| mock_resp.__enter__ = MagicMock(return_value=mock_resp) | ||
| mock_resp.__exit__ = MagicMock(return_value=False) | ||
| mock_urlopen.return_value = mock_resp | ||
|
|
||
| assert _fetch_latest_version() is None | ||
|
|
||
| @patch("update_check.ssl.create_default_context") | ||
| @patch("update_check.urllib.request.urlopen") | ||
| @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") | ||
| def test_returns_none_on_empty_tag(self, mock_ca, mock_urlopen, mock_ssl): | ||
| response_data = json.dumps({"tag_name": ""}).encode("utf-8") | ||
| mock_resp = MagicMock() | ||
| mock_resp.read.return_value = response_data | ||
| mock_resp.__enter__ = MagicMock(return_value=mock_resp) | ||
| mock_resp.__exit__ = MagicMock(return_value=False) | ||
| mock_urlopen.return_value = mock_resp | ||
|
|
||
| assert _fetch_latest_version() is None | ||
|
|
||
|
|
||
| class TestIsUpdateAvailable: | ||
| """Tests for _is_update_available().""" | ||
|
|
||
| def test_newer_version_available(self): | ||
| assert _is_update_available("0.5.0", "0.6.5") is True | ||
|
|
||
| def test_same_version(self): | ||
| assert _is_update_available("0.6.5", "0.6.5") is False | ||
|
|
||
| def test_older_version(self): | ||
| assert _is_update_available("1.0.0", "0.6.5") is False | ||
karthikbekalp marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| def test_invalid_current_version(self): | ||
| assert _is_update_available("not-a-version", "0.6.5") is False | ||
|
|
||
| def test_invalid_latest_version(self): | ||
| assert _is_update_available("0.6.5", "not-a-version") is False | ||
|
|
||
| def test_dev_build_is_older_than_release(self): | ||
| assert _is_update_available("0.6.5.post144", "0.7.0") is True | ||
|
|
||
| def test_release_is_newer_than_dev_build(self): | ||
| assert _is_update_available("0.7.0", "0.6.5.post144") is False | ||
|
|
||
| def test_post_release_does_not_trigger_update_for_same_base(self): | ||
| assert _is_update_available("0.6.5.post144", "0.6.5") is False | ||
|
|
||
|
|
||
| class TestCheckAndShowUpdateDialog: | ||
| """Tests for safe_check_and_show_update_dialog().""" | ||
|
|
||
| @patch("update_check._is_update_notification_enabled", return_value=False) | ||
| def test_returns_false_when_notifications_disabled(self, mock_enabled): | ||
| assert safe_check_and_show_update_dialog() is False | ||
|
|
||
| @patch("update_check._fetch_latest_version", return_value=None) | ||
| @patch("update_check._get_current_version", return_value="0.5.0") | ||
| @patch("update_check._is_update_notification_enabled", return_value=True) | ||
| def test_returns_false_when_fetch_fails(self, mock_enabled, mock_current, mock_fetch): | ||
| assert safe_check_and_show_update_dialog() is False | ||
|
|
||
| @patch("update_check._is_update_available", return_value=False) | ||
| @patch("update_check._fetch_latest_version", return_value="0.5.0") | ||
| @patch("update_check._get_current_version", return_value="0.5.0") | ||
| @patch("update_check._is_update_notification_enabled", return_value=True) | ||
| def test_returns_false_when_already_up_to_date( | ||
| self, mock_enabled, mock_current, mock_fetch, mock_available | ||
| ): | ||
| assert safe_check_and_show_update_dialog() is False | ||
|
|
||
| @patch("update_check.webbrowser.open") | ||
| @patch("update_check._is_update_available", return_value=True) | ||
| @patch("update_check._fetch_latest_version", return_value="0.6.5") | ||
| @patch("update_check._get_current_version", return_value="0.5.0") | ||
| @patch("update_check._is_update_notification_enabled", return_value=True) | ||
| def test_returns_true_when_user_clicks_yes( | ||
| self, mock_enabled, mock_current, mock_fetch, mock_available, mock_webbrowser | ||
| ): | ||
| _mock_unreal.AppReturnType.YES = "YES" | ||
| _mock_unreal.EditorDialog.show_message.return_value = "YES" | ||
|
|
||
| result = safe_check_and_show_update_dialog() | ||
|
|
||
| assert result is True | ||
| mock_webbrowser.assert_called_once_with(RELEASES_PAGE_URL) | ||
| # Should show two dialogs: the update dialog and the restart reminder | ||
| assert _mock_unreal.EditorDialog.show_message.call_count == 2 | ||
|
|
||
| @patch("update_check._is_update_available", return_value=True) | ||
| @patch("update_check._fetch_latest_version", return_value="0.6.5") | ||
| @patch("update_check._get_current_version", return_value="0.5.0") | ||
| @patch("update_check._is_update_notification_enabled", return_value=True) | ||
| def test_returns_false_when_user_clicks_no( | ||
| self, mock_enabled, mock_current, mock_fetch, mock_available | ||
| ): | ||
| _mock_unreal.AppReturnType.YES = "YES" | ||
| _mock_unreal.EditorDialog.show_message.return_value = "NO" | ||
|
|
||
| result = safe_check_and_show_update_dialog() | ||
|
|
||
| assert result is False | ||
|
|
||
| @patch("update_check.webbrowser.open", side_effect=Exception("browser error")) | ||
| @patch("update_check._is_update_available", return_value=True) | ||
| @patch("update_check._fetch_latest_version", return_value="0.6.5") | ||
| @patch("update_check._get_current_version", return_value="0.5.0") | ||
| @patch("update_check._is_update_notification_enabled", return_value=True) | ||
| def test_returns_false_when_webbrowser_fails( | ||
| self, mock_enabled, mock_current, mock_fetch, mock_available, mock_webbrowser | ||
| ): | ||
| _mock_unreal.AppReturnType.YES = "YES" | ||
| _mock_unreal.EditorDialog.show_message.return_value = "YES" | ||
|
|
||
| result = safe_check_and_show_update_dialog() | ||
|
|
||
| assert result is False | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.