Browse Source

Merge branch 'CURA-7290_manual_account_sync' of github.com:Ultimaker/Cura

Jaime van Kessel 4 years ago
parent
commit
280d3f07a6

+ 44 - 13
cura/API/Account.py

@@ -23,6 +23,7 @@ class SyncState:
     SYNCING = 0
     SUCCESS = 1
     ERROR = 2
+    IDLE = 3
 
 
 ##  The account API provides a version-proof bridge to use Ultimaker Accounts
@@ -50,6 +51,7 @@ class Account(QObject):
     """
     lastSyncDateTimeChanged = pyqtSignal()
     syncStateChanged = pyqtSignal(int)  # because SyncState is an int Enum
+    manualSyncEnabledChanged = pyqtSignal(bool)
 
     def __init__(self, application: "CuraApplication", parent = None) -> None:
         super().__init__(parent)
@@ -58,7 +60,8 @@ class Account(QObject):
 
         self._error_message = None  # type: Optional[Message]
         self._logged_in = False
-        self._sync_state = SyncState.SUCCESS
+        self._sync_state = SyncState.IDLE
+        self._manual_sync_enabled = False
         self._last_sync_str = "-"
 
         self._callback_port = 32118
@@ -106,16 +109,21 @@ class Account(QObject):
         :param state: One of SyncState
         """
 
+        Logger.info("Service {service} enters sync state {state}", service = service_name, state = state)
+
         prev_state = self._sync_state
 
         self._sync_services[service_name] = state
 
         if any(val == SyncState.SYNCING for val in self._sync_services.values()):
             self._sync_state = SyncState.SYNCING
+            self._setManualSyncEnabled(False)
         elif any(val == SyncState.ERROR for val in self._sync_services.values()):
             self._sync_state = SyncState.ERROR
+            self._setManualSyncEnabled(True)
         else:
             self._sync_state = SyncState.SUCCESS
+            self._setManualSyncEnabled(False)
 
         if self._sync_state != prev_state:
             self.syncStateChanged.emit(self._sync_state)
@@ -157,11 +165,31 @@ class Account(QObject):
             self._logged_in = logged_in
             self.loginStateChanged.emit(logged_in)
             if logged_in:
-                self.sync()
+                self._setManualSyncEnabled(False)
+                self._sync()
             else:
                 if self._update_timer.isActive():
                     self._update_timer.stop()
 
+    def _sync(self) -> None:
+        """Signals all sync services to start syncing
+
+        This can be considered a forced sync: even when a
+        sync is currently running, a sync will be requested.
+        """
+
+        if self._update_timer.isActive():
+            self._update_timer.stop()
+        elif self._sync_state == SyncState.SYNCING:
+            Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
+
+        self.syncRequested.emit()
+
+    def _setManualSyncEnabled(self, enabled: bool) -> None:
+        if self._manual_sync_enabled != enabled:
+            self._manual_sync_enabled = enabled
+            self.manualSyncEnabledChanged.emit(enabled)
+
     @pyqtSlot()
     @pyqtSlot(bool)
     def login(self, force_logout_before_login: bool = False) -> None:
@@ -212,20 +240,23 @@ class Account(QObject):
     def lastSyncDateTime(self) -> str:
         return self._last_sync_str
 
-    @pyqtSlot()
-    def sync(self) -> None:
-        """Signals all sync services to start syncing
+    @pyqtProperty(bool, notify=manualSyncEnabledChanged)
+    def manualSyncEnabled(self) -> bool:
+        return self._manual_sync_enabled
 
-        This can be considered a forced sync: even when a
-        sync is currently running, a sync will be requested.
-        """
+    @pyqtSlot()
+    @pyqtSlot(bool)
+    def sync(self, user_initiated: bool = False) -> None:
+        if user_initiated:
+            self._setManualSyncEnabled(False)
 
-        if self._update_timer.isActive():
-            self._update_timer.stop()
-        elif self._sync_state == SyncState.SYNCING:
-            Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
+        self._sync()
 
-        self.syncRequested.emit()
+    @pyqtSlot()
+    def popupOpened(self) -> None:
+        self._setManualSyncEnabled(True)
+        self._sync_state = SyncState.IDLE
+        self.syncStateChanged.emit(self._sync_state)
 
     @pyqtSlot()
     def logout(self) -> None:

+ 1 - 0
plugins/Toolbox/src/CloudSync/CloudPackageChecker.py

@@ -67,6 +67,7 @@ class CloudPackageChecker(QObject):
         self._application.getHttpRequestManager().get(url,
                                                       callback = self._onUserPackagesRequestFinished,
                                                       error_callback = self._onUserPackagesRequestFinished,
+                                                      timeout=10,
                                                       scope = self._scope)
 
     def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None:

+ 46 - 26
plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py

@@ -6,11 +6,15 @@ from time import time
 from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast
 
 from PyQt5.QtCore import QUrl
-from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
+from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
 
 from UM.Logger import Logger
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager
+from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
 from cura.API import Account
+from cura.CuraApplication import CuraApplication
 from cura.UltimakerCloud import UltimakerCloudAuthentication
+from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
 from .ToolPathUploader import ToolPathUploader
 from ..Models.BaseModel import BaseModel
 from ..Models.Http.CloudClusterResponse import CloudClusterResponse
@@ -33,16 +37,20 @@ class CloudApiClient:
     CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH)
     CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
 
+    DEFAULT_REQUEST_TIMEOUT = 10  # seconds
+
     # In order to avoid garbage collection we keep the callbacks in this list.
-    _anti_gc_callbacks = []  # type: List[Callable[[], None]]
+    _anti_gc_callbacks = []  # type: List[Callable[[Any], None]]
 
     ## Initializes a new cloud API client.
     #  \param account: The user's account object
     #  \param on_error: The callback to be called whenever we receive errors from the server.
-    def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None:
+    def __init__(self, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None:
         super().__init__()
-        self._manager = QNetworkAccessManager()
-        self._account = account
+        self._app = app
+        self._account = app.getCuraAPI().account
+        self._scope = JsonDecoratorScope(UltimakerCloudScope(app))
+        self._http = HttpRequestManager.getInstance()
         self._on_error = on_error
         self._upload = None  # type: Optional[ToolPathUploader]
 
@@ -55,16 +63,21 @@ class CloudApiClient:
     #  \param on_finished: The function to be called after the result is parsed.
     def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None:
         url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
-        reply = self._manager.get(self._createEmptyRequest(url))
-        self._addCallback(reply, on_finished, CloudClusterResponse, failed)
+        self._http.get(url,
+                       scope = self._scope,
+                       callback = self._parseCallback(on_finished, CloudClusterResponse, failed),
+                       error_callback = failed,
+                       timeout = self.DEFAULT_REQUEST_TIMEOUT)
 
     ## Retrieves the status of the given cluster.
     #  \param cluster_id: The ID of the cluster.
     #  \param on_finished: The function to be called after the result is parsed.
     def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None:
         url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
-        reply = self._manager.get(self._createEmptyRequest(url))
-        self._addCallback(reply, on_finished, CloudClusterStatus)
+        self._http.get(url,
+                       scope = self._scope,
+                       callback = self._parseCallback(on_finished, CloudClusterStatus),
+                       timeout = self.DEFAULT_REQUEST_TIMEOUT)
 
     ## Requests the cloud to register the upload of a print job mesh.
     #  \param request: The request object.
@@ -72,9 +85,13 @@ class CloudApiClient:
     def requestUpload(self, request: CloudPrintJobUploadRequest,
                       on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
         url = "{}/jobs/upload".format(self.CURA_API_ROOT)
-        body = json.dumps({"data": request.toDict()})
-        reply = self._manager.put(self._createEmptyRequest(url), body.encode())
-        self._addCallback(reply, on_finished, CloudPrintJobResponse)
+        data = json.dumps({"data": request.toDict()}).encode()
+
+        self._http.put(url,
+                        scope = self._scope,
+                        data = data,
+                        callback = self._parseCallback(on_finished, CloudPrintJobResponse),
+                        timeout = self.DEFAULT_REQUEST_TIMEOUT)
 
     ## Uploads a print job tool path to the cloud.
     #  \param print_job: The object received after requesting an upload with `self.requestUpload`.
@@ -84,7 +101,7 @@ class CloudApiClient:
     #  \param on_error: A function to be called if the upload fails.
     def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
                        on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
-        self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error)
+        self._upload = ToolPathUploader(self._http, print_job, mesh, on_finished, on_progress, on_error)
         self._upload.start()
 
     # Requests a cluster to print the given print job.
@@ -93,8 +110,11 @@ class CloudApiClient:
     #  \param on_finished: The function to be called after the result is parsed.
     def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None:
         url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id)
-        reply = self._manager.post(self._createEmptyRequest(url), b"")
-        self._addCallback(reply, on_finished, CloudPrintResponse)
+        self._http.post(url,
+                       scope = self._scope,
+                       data = b"",
+                       callback = self._parseCallback(on_finished, CloudPrintResponse),
+                       timeout = self.DEFAULT_REQUEST_TIMEOUT)
 
     ##  Send a print job action to the cluster for the given print job.
     #  \param cluster_id: The ID of the cluster.
@@ -104,7 +124,10 @@ class CloudApiClient:
                          data: Optional[Dict[str, Any]] = None) -> None:
         body = json.dumps({"data": data}).encode() if data else b""
         url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action)
-        self._manager.post(self._createEmptyRequest(url), body)
+        self._http.post(url,
+                        scope = self._scope,
+                        data = body,
+                        timeout = self.DEFAULT_REQUEST_TIMEOUT)
 
     ##  We override _createEmptyRequest in order to add the user credentials.
     #   \param url: The URL to request
@@ -162,13 +185,12 @@ class CloudApiClient:
     #  \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
     #       a list or a single item.
     #  \param model: The type of the model to convert the response to.
-    def _addCallback(self,
-                     reply: QNetworkReply,
-                     on_finished: Union[Callable[[CloudApiClientModel], Any],
-                                        Callable[[List[CloudApiClientModel]], Any]],
-                     model: Type[CloudApiClientModel],
-                     on_error: Optional[Callable] = None) -> None:
-        def parse() -> None:
+    def _parseCallback(self,
+                       on_finished: Union[Callable[[CloudApiClientModel], Any],
+                                          Callable[[List[CloudApiClientModel]], Any]],
+                       model: Type[CloudApiClientModel],
+                       on_error: Optional[Callable] = None) -> Callable[[QNetworkReply], None]:
+        def parse(reply: QNetworkReply) -> None:
             self._anti_gc_callbacks.remove(parse)
 
             # Don't try to parse the reply if we didn't get one
@@ -184,6 +206,4 @@ class CloudApiClient:
                 self._parseModels(response, on_finished, model)
 
         self._anti_gc_callbacks.append(parse)
-        reply.finished.connect(parse)
-        if on_error is not None:
-            reply.error.connect(on_error)
+        return parse

+ 4 - 3
plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py

@@ -4,6 +4,7 @@ import os
 from typing import Dict, List, Optional
 
 from PyQt5.QtCore import QTimer
+from PyQt5.QtNetwork import QNetworkReply
 
 from UM import i18nCatalog
 from UM.Logger import Logger  # To log errors talking to the API.
@@ -40,7 +41,7 @@ class CloudOutputDeviceManager:
         # Persistent dict containing the remote clusters for the authenticated user.
         self._remote_clusters = {}  # type: Dict[str, CloudOutputDevice]
         self._account = CuraApplication.getInstance().getCuraAPI().account  # type: Account
-        self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error)))
+        self._api = CloudApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))
         self._account.loginStateChanged.connect(self._onLoginStateChanged)
 
         # Ensure we don't start twice.
@@ -118,7 +119,7 @@ class CloudOutputDeviceManager:
         self._syncing = False
         self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
 
-    def _onGetRemoteClusterFailed(self):
+    def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
         self._syncing = False
         self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
 
@@ -284,4 +285,4 @@ class CloudOutputDeviceManager:
 
         output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
         if device.key not in output_device_manager.getOutputDeviceIds():
-            output_device_manager.addOutputDevice(device)
+            output_device_manager.addOutputDevice(device)

+ 25 - 30
plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py

@@ -1,11 +1,11 @@
 # Copyright (c) 2019 Ultimaker B.V.
 # !/usr/bin/env python
 # -*- coding: utf-8 -*-
-from PyQt5.QtCore import QUrl
-from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
-from typing import Optional, Callable, Any, Tuple, cast
+from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
+from typing import Callable, Any, Tuple, cast, Dict, Optional
 
 from UM.Logger import Logger
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager
 
 from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
 
@@ -23,16 +23,16 @@ class ToolPathUploader:
     BYTES_PER_REQUEST = 256 * 1024
 
     ## Creates a mesh upload object.
-    #  \param manager: The network access manager that will handle the HTTP requests.
+    #  \param http: The HttpRequestManager that will handle the HTTP requests.
     #  \param print_job: The print job response that was returned by the cloud after registering the upload.
     #  \param data: The mesh bytes to be uploaded.
     #  \param on_finished: The method to be called when done.
     #  \param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
     #  \param on_error: The method to be called when an error occurs.
-    def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes,
+    def __init__(self, http: HttpRequestManager, print_job: CloudPrintJobResponse, data: bytes,
                  on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]
                  ) -> None:
-        self._manager = manager
+        self._http = http
         self._print_job = print_job
         self._data = data
 
@@ -43,25 +43,12 @@ class ToolPathUploader:
         self._sent_bytes = 0
         self._retries = 0
         self._finished = False
-        self._reply = None  # type: Optional[QNetworkReply]
 
     ## Returns the print job for which this object was created.
     @property
     def printJob(self):
         return self._print_job
 
-    ##  Creates a network request to the print job upload URL, adding the needed content range header.
-    def _createRequest(self) -> QNetworkRequest:
-        request = QNetworkRequest(QUrl(self._print_job.upload_url))
-        request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type)
-
-        first_byte, last_byte = self._chunkRange()
-        content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
-        request.setRawHeader(b"Content-Range", content_range.encode())
-        Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
-
-        return request
-
     ## Determines the bytes that should be uploaded next.
     #  \return: A tuple with the first and the last byte to upload.
     def _chunkRange(self) -> Tuple[int, int]:
@@ -88,13 +75,23 @@ class ToolPathUploader:
             raise ValueError("The upload is already finished")
 
         first_byte, last_byte = self._chunkRange()
-        request = self._createRequest()
+        content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
+
+        headers = {
+            "Content-Type": cast(str, self._print_job.content_type),
+            "Content-Range": content_range
+        }  # type: Dict[str, str]
+
+        Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
 
-        # now send the reply and subscribe to the results
-        self._reply = self._manager.put(request, self._data[first_byte:last_byte])
-        self._reply.finished.connect(self._finishedCallback)
-        self._reply.uploadProgress.connect(self._progressCallback)
-        self._reply.error.connect(self._errorCallback)
+        self._http.put(
+            url = cast(str, self._print_job.upload_url),
+            headers_dict = headers,
+            data = self._data[first_byte:last_byte],
+            callback = self._finishedCallback,
+            error_callback = self._errorCallback,
+            upload_progress_callback = self._progressCallback
+        )
 
     ## Handles an update to the upload progress
     #  \param bytes_sent: The amount of bytes sent in the current request.
@@ -106,16 +103,14 @@ class ToolPathUploader:
             self._on_progress(int(total_sent / len(self._data) * 100))
 
     ## Handles an error uploading.
-    def _errorCallback(self) -> None:
-        reply = cast(QNetworkReply, self._reply)
+    def _errorCallback(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
         body = bytes(reply.readAll()).decode()
         Logger.log("e", "Received error while uploading: %s", body)
         self.stop()
         self._on_error()
 
     ## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
-    def _finishedCallback(self) -> None:
-        reply = cast(QNetworkReply, self._reply)
+    def _finishedCallback(self, reply: QNetworkReply) -> None:
         Logger.log("i", "Finished callback %s %s",
                    reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
 
@@ -133,7 +128,7 @@ class ToolPathUploader:
 
         # Http codes that are not to be retried are assumed to be errors.
         if status_code > 308:
-            self._errorCallback()
+            self._errorCallback(reply, None)
             return
 
         Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,

+ 10 - 1
resources/qml/Account/AccountWidget.qml

@@ -108,7 +108,15 @@ Item
             }
         }
 
-        onClicked: popup.opened ? popup.close() : popup.open()
+        onClicked: {
+            if (popup.opened)
+            {
+                popup.close()
+            } else {
+                Cura.API.account.popupOpened()
+                popup.open()
+            }
+        }
     }
 
     Popup
@@ -119,6 +127,7 @@ Item
         x: parent.width - width
 
         closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
+        onOpened: Cura.API.account.popupOpened()
 
         opacity: opened ? 1 : 0
         Behavior on opacity { NumberAnimation { duration: 100 } }

+ 18 - 19
resources/qml/Account/SyncState.qml

@@ -7,11 +7,7 @@ import Cura 1.1 as Cura
 Row // sync state icon + message
 {
 
-    property alias iconSource: icon.source
-    property alias labelText: stateLabel.text
-    property alias syncButtonVisible: accountSyncButton.visible
-    property alias animateIconRotation: updateAnimator.running
-
+    id: syncRow
     width: childrenRect.width
     height: childrenRect.height
     anchors.horizontalCenter: parent.horizontalCenter
@@ -23,7 +19,7 @@ Row // sync state icon + message
         width: 20 * screenScaleFactor
         height: width
 
-        source: UM.Theme.getIcon("update")
+        source: Cura.API.account.manualSyncEnabled ? UM.Theme.getIcon("update") : UM.Theme.getIcon("checked")
         color: palette.text
 
         RotationAnimator
@@ -54,10 +50,11 @@ Row // sync state icon + message
         Label
         {
             id: stateLabel
-            text: catalog.i18nc("@state", "Checking...")
+            text: catalog.i18nc("@state", catalog.i18nc("@label", "You are in sync with your account"))
             color: UM.Theme.getColor("text")
             font: UM.Theme.getFont("medium")
             renderType: Text.NativeRendering
+            visible: !Cura.API.account.manualSyncEnabled
         }
 
         Label
@@ -67,11 +64,13 @@ Row // sync state icon + message
             color: UM.Theme.getColor("secondary_button_text")
             font: UM.Theme.getFont("medium")
             renderType: Text.NativeRendering
+            visible: Cura.API.account.manualSyncEnabled
+            height: visible ? accountSyncButton.intrinsicHeight : 0
 
             MouseArea
             {
                 anchors.fill: parent
-                onClicked: Cura.API.account.sync()
+                onClicked: Cura.API.account.sync(true)
                 hoverEnabled: true
                 onEntered: accountSyncButton.font.underline = true
                 onExited: accountSyncButton.font.underline = false
@@ -82,25 +81,25 @@ Row // sync state icon + message
     signal syncStateChanged(string newState)
 
     onSyncStateChanged: {
-        if(newState == Cura.AccountSyncState.SYNCING){
-            syncRow.iconSource = UM.Theme.getIcon("update")
-            syncRow.labelText = catalog.i18nc("@label", "Checking...")
+        if(newState == Cura.AccountSyncState.IDLE){
+            icon.source = UM.Theme.getIcon("update")
+        } else if(newState == Cura.AccountSyncState.SYNCING){
+            icon.source = UM.Theme.getIcon("update")
+            stateLabel.text = catalog.i18nc("@label", "Checking...")
         } else if (newState == Cura.AccountSyncState.SUCCESS) {
-            syncRow.iconSource = UM.Theme.getIcon("checked")
-            syncRow.labelText = catalog.i18nc("@label", "You are up to date")
+            icon.source = UM.Theme.getIcon("checked")
+            stateLabel.text = catalog.i18nc("@label", "You are in sync with your account")
         } else if (newState == Cura.AccountSyncState.ERROR) {
-            syncRow.iconSource = UM.Theme.getIcon("warning_light")
-            syncRow.labelText = catalog.i18nc("@label", "Something went wrong...")
+            icon.source = UM.Theme.getIcon("warning_light")
+            stateLabel.text = catalog.i18nc("@label", "Something went wrong...")
         } else {
             print("Error: unexpected sync state: " + newState)
         }
 
         if(newState == Cura.AccountSyncState.SYNCING){
-            syncRow.animateIconRotation = true
-            syncRow.syncButtonVisible = false
+            updateAnimator.running = true
         } else {
-            syncRow.animateIconRotation = false
-            syncRow.syncButtonVisible = true
+            updateAnimator.running = false
         }
     }
 

+ 5 - 5
resources/qml/Account/UserOperations.qml

@@ -9,7 +9,10 @@ import Cura 1.1 as Cura
 
 Column
 {
-    width: Math.max(title.width, accountButton.width) + 2 * UM.Theme.getSize("default_margin").width
+    width: Math.max(
+            Math.max(title.width, accountButton.width) + 2 * UM.Theme.getSize("default_margin").width,
+            syncRow.width
+           )
 
     spacing: UM.Theme.getSize("default_margin").height
 
@@ -29,13 +32,10 @@ Column
         color: UM.Theme.getColor("text")
     }
 
-    SyncState
-    {
+    SyncState {
         id: syncRow
     }
 
-
-
     Label
     {
         id: lastSyncLabel

+ 3 - 3
resources/themes/cura-light/icons/checked.svg

@@ -4,9 +4,9 @@
     <title>checked</title>
     <desc>Created with Sketch.</desc>
     <g id="checked" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <g id="Check-circle" fill="#000000" fill-rule="nonzero">
-            <path d="M8,0 C3.581722,0 1.42108547e-15,3.581722 1.42108547e-15,8 C1.42108547e-15,12.418278 3.581722,16 8,16 C12.418278,16 16,12.418278 16,8 C16,5.87826808 15.1571453,3.84343678 13.6568542,2.34314575 C12.1565632,0.842854723 10.1217319,0 8,0 Z M8,14.4 C4.4653776,14.4 1.6,11.5346224 1.6,8 C1.6,4.4653776 4.4653776,1.6 8,1.6 C11.5346224,1.6 14.4,4.4653776 14.4,8 C14.4,9.69738553 13.7257162,11.3252506 12.5254834,12.5254834 C11.3252506,13.7257162 9.69738553,14.4 8,14.4 Z" id="Shape"></path>
-            <polygon id="Path" points="11.44 5.04 7.2 9.28 4.56 6.64 3.44 7.76 7.2 11.52 12.56 6.16"></polygon>
+        <g transform="translate(1.000000, 1.000000)" fill="#000000" fill-rule="nonzero">
+            <path d="M7,0 C3.13400675,0 1.55431223e-15,3.13400675 1.55431223e-15,7 C1.55431223e-15,10.8659933 3.13400675,14 7,14 C10.8659933,14 14,10.8659933 14,7 C14,5.14348457 13.2625021,3.36300718 11.9497474,2.05025253 C10.6369928,0.737497883 8.85651541,0 7,0 Z M7,12.6 C3.9072054,12.6 1.4,10.0927946 1.4,7 C1.4,3.9072054 3.9072054,1.4 7,1.4 C10.0927946,1.4 12.6,3.9072054 12.6,7 C12.6,8.48521234 12.0100017,9.90959428 10.959798,10.959798 C9.90959428,12.0100017 8.48521234,12.6 7,12.6 Z" id="Shape"></path>
+            <polygon id="Path" points="10.01 4.41 6.3 8.12 3.99 5.81 3.01 6.79 6.3 10.08 10.99 5.39"></polygon>
         </g>
     </g>
 </svg>