Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/user_guide/setup-submitter.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,21 @@ This example will use the Meerkat Demo from the Unreal Marketplace:
1. Note: Unreal Engine version autodetection is coming in a future release

1. Ready to Go! Hit "Render (Remote)".
1. You can go to Deadline Cloud Monitor and watch the progress of your job.
1. You can go to Deadline Cloud Monitor and watch the progress of your job.


# Update Notifications

The submitter plugin automatically checks for newer releases on GitHub when Unreal Editor starts. If an update is available, a dialog will prompt you to visit the release page.

To deactivate update notifications:

```
deadline config set settings.submitter_update_notification false
```

To re-enable:

```
deadline config set settings.submitter_update_notification true
```
4 changes: 4 additions & 0 deletions src/unreal_plugin/Content/Python/init_unreal.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ def __init__(

logger.info("INIT DEADLINE CLOUD")

from update_check import safe_check_and_show_update_dialog

safe_check_and_show_update_dialog()

logger.info(f'DEADLINE CLOUD PATH: {os.getenv("DEADLINE_CLOUD")}')

# These unused imports are REQUIRED!!!
Expand Down
151 changes: 151 additions & 0 deletions src/unreal_plugin/Content/Python/update_check.py
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)
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 test/deadline_submitter_for_unreal/unit/test_update_check.py
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

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
Loading