Manage Qt Quick applications for WebGL Streaming

This script plugin creates a convenience UI that lets you start and stop Qt Quick applications with WebGL streaming enabled.

The script plugin is destroyed when VRED shuts down or when all script plugins are reloaded. When this happens, we want to stop all created processes again, to not leave orphan processes behind. To do this, the function onDestroyVREDScriptPlugin() has been implemented. It is called automatically before the plugin is destroyed.

Each script plugin can (but is not required to) implement onDestroyVREDScriptPlugin() to do something just before the current instance of the plugin is destroyed.

QtQuickStreaming.py
  1from PySide6 import QtCore, QtWidgets, QtGui, QtNetwork
  2from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox
  3from PySide6.QtCore import QFile, Signal, Slot, QObject, QProcess, QProcessEnvironment
  4from PySide6.QtNetwork import QTcpSocket
  5
  6import os, signal
  7import uiTools
  8
  9from vrController import vrLogError, vrLogWarning, vrLogInfo
 10
 11"""
 12 This script plugin creates a convenience UI that lets you start and stop Qt Quick applications 
 13 with WebGL streaming enabled. Starting a process with this UI starts it as a child process of VRED.
 14 To work without the UI, you can use this command line:
 15 $ ./your-qt-application -platform webgl:port=8998
 16"""
 17
 18# Load the .ui files. We derive widget classes from these types.
 19QtQuickStreaming_form, QtQuickStreaming_base = uiTools.loadUiType('QtQuickStreaming.ui')
 20ProcessWidget_form, ProcessWidget_base = uiTools.loadUiType('process.ui')
 21
 22
 23def getIcon(name):
 24    """Returns a QIcon for a button or action."""
 25    icon = QtGui.QIcon()
 26    iconPath = "resources:General/" + name
 27    icon.addPixmap(QtGui.QPixmap("{}Disabled.svg".format(iconPath)), QtGui.QIcon.Disabled, QtGui.QIcon.Off)
 28    icon.addPixmap(QtGui.QPixmap("{}OffNormal.svg".format(iconPath)), QtGui.QIcon.Normal, QtGui.QIcon.Off)
 29    return icon
 30
 31
 32class RunningState:
 33    """ Indicates the state of the process."""
 34    STOPPED = 0
 35    STARTED = 1
 36
 37    @staticmethod
 38    def createIndicatorPixmap(color):
 39        """Creates and returns a QPixmap with a circle as indicator for the process running state."""
 40        pixmap = QtGui.QPixmap(12, 12)
 41        pixmap.fill(QtCore.Qt.transparent)
 42        painter = QtGui.QPainter(pixmap)
 43        painter.setRenderHint(QtGui.QPainter.Antialiasing)
 44        painter.setPen(QtCore.Qt.NoPen)
 45        painter.setBrush(QtGui.QBrush(QtGui.QColor(color)))
 46        painter.drawEllipse(1, 1, 10, 10)
 47        return pixmap
 48
 49
 50class ProcessObject(QObject):
 51    """ 
 52    Small wrapper around a QProcess object. 
 53    This class is responsible for starting and stopping a process and logging errors.
 54    Attributes:
 55        procName (str): Full application path
 56        port (int): Port for WebGL streaming
 57        process (QProcess): We use Qt to manage the process
 58        runningState (int): RunningState.STARTED or RunningState.STOPPED
 59    """
 60    runningStateChanged = Signal(int)
 61    def __init__(self, name, port):
 62        super(ProcessObject, self).__init__()
 63        self.procName = name
 64        self.port = port
 65        self.process = None
 66        self.runningState = RunningState.STOPPED
 67
 68    def startProcess(self):
 69        if self.isRunning():
 70            return
 71        if not self.isPortAvailable(self.port):
 72            vrLogWarning("{}: Port {} already in use.".format(self.procName, str(self.port)))
 73        process = QProcess()
 74        self.process = process
 75        process.started.connect(self.processStarted)
 76        process.errorOccurred.connect(self.processError)
 77        process.finished.connect(self.processFinished)
 78        process.readyReadStandardError.connect(self.processStandardError)
 79        process.setWorkingDirectory(os.path.dirname(self.procName))
 80 
 81        # Enable WebGL platform for this process and set port via QT_QPA_PLATFORM environment 
 82        # variable. This is an alternative to using the -platform command line parameter.
 83        env = QProcessEnvironment.systemEnvironment()
 84        env.insert("QT_QPA_PLATFORM", "webgl:port={}".format(str(self.port)))
 85        process.setProcessEnvironment(env)
 86        process.start("\"{}\"".format(self.procName))
 87
 88    def stopProcess(self):
 89        try:
 90            if self.isRunning():
 91                os.kill(self.process.processId(), signal.SIGTERM)
 92                self.process.waitForFinished(10000)
 93                self.process = None
 94        except:
 95            pass
 96
 97    def isRunning(self):
 98        return (self.runningState == RunningState.STARTED and self.process is not None)
 99
100    def isPortAvailable(self, port):
101        socket = QTcpSocket()
102        free = socket.bind(port, QTcpSocket.DontShareAddress)
103        socket.close()
104        return free
105
106    def processStarted(self):
107        print("{} ({}): Process started.".format(self.procName, self.port))
108        self.setRunningState(RunningState.STARTED)
109
110    def processFinished(self, exitCode, exitStatus):
111        print("{} ({}): Process finished.".format(self.procName, self.port))
112        self.setRunningState(RunningState.STOPPED)
113
114    def processError(self, err):
115        if self.process is not None:
116            vrLogError("{}: {}".format(self.procName, self.process.errorString()))
117
118    def processStandardError(self):
119        if self.process is not None:
120            vrLogError("{}:\n{}".format(self.procName, self.process.readAllStandardError()))
121
122    def getPath(self):
123        return self.procName
124
125    def getPort(self):
126        return self.port
127
128    def getRunningState(self):
129        return self.runningState
130
131    def setRunningState(self, state):
132        self.runningState = state
133        self.runningStateChanged.emit(state)
134
135
136class ProcessWidget(ProcessWidget_form, ProcessWidget_base):
137    """
138    This widget holds the UI for one entry of the process list.
139    Attributes:
140        process (ProcessObject): The process represented by this widget
141        id (int): Id of the widget to be able to find it in the list
142    """
143    deleteSignal = Signal(int)  
144    id = 0
145    def __init__(self, parent, process):
146        super(ProcessWidget, self).__init__(parent)
147        self.setupUi(self)
148        self.process = process
149        ProcessWidget.id += 1
150        self.id = ProcessWidget.id
151
152        self.startButton.clicked.connect(self._onStartButtonClicked)
153        self.stopButton.clicked.connect(self._onStopButtonClicked)
154        self.deleteButton.clicked.connect(self._onDeleteButtonClicked)
155        self.copyURLButton.clicked.connect(self._onCopyURLButtonClicked)
156        self.process.runningStateChanged.connect(self._onRunningStateChanged)
157
158        self.startButton.setIcon(getIcon("Run"))
159        self.stopButton.setIcon(getIcon("Stop"))
160        self.deleteButton.setIcon(getIcon("Delete"))
161
162        self.procLabel.setText(os.path.basename(self.process.procName))
163        self.procLabel.setToolTip(self.process.procName)
164        self.procLabel.setStatusTip(self.process.procName)
165        self.portEdit.setValue(self.process.port)
166        self.stoppedPixmap = RunningState.createIndicatorPixmap("#3c3c3c")
167        self.startedPixmap = RunningState.createIndicatorPixmap("#41d971")
168        self.updateUI()
169
170    def updateUI(self):
171        started = self.process.runningState == RunningState.STARTED
172        self.startButton.setEnabled(not started)
173        self.stopButton.setEnabled(started)
174        self.portEdit.setReadOnly(started)
175        runningPixmap = self.startedPixmap if started else self.stoppedPixmap
176        self.runningLabel.setPixmap(runningPixmap)
177
178    def _onStartButtonClicked(self):
179        self.process.port = self.portEdit.value()
180        self.process.startProcess()
181
182    def _onStopButtonClicked(self):
183        self.process.stopProcess()
184
185    def _onDeleteButtonClicked(self):
186        self.process.stopProcess()
187        self.deleteSignal.emit(self.id)
188
189    def _onCopyURLButtonClicked(self):
190        url = "http://localhost:{}".format(self.process.port)
191        QApplication.clipboard().setText(url)
192
193    def _onRunningStateChanged(self, state):
194        self.updateUI()
195
196
197class QtQuickStreaming(QtQuickStreaming_form, QtQuickStreaming_base):
198    """
199    This is the main widget for the plugin. It holds a list of processes.
200    Attributes:
201        parent (QWidget): Parent widget
202        processWidgets (dict of int:ProcessWidget): Holds all processes, maps id to process widget
203        lastConfigFile (str): The config file that was loaded last
204    """
205    def __init__(self, parent=None):
206        super(QtQuickStreaming, self).__init__(parent)
207        parent.layout().addWidget(self)
208        self.parent = parent
209        self.setupUi(self)
210        # This class derives from QMainWindow so that we can have a tool bar and a menu bar.
211        # To be able to embed it into the parent widget provided by VRED we need to 
212        # remove the Window flag.
213        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.Window);
214        self.processWidgets = {}
215        self.lastConfigFile = ""
216
217        # signal connections
218        self.actionAdd.triggered.connect(self._onAdd)
219        self.actionStartAll.triggered.connect(self._onStartAll)
220        self.actionStopAll.triggered.connect(self._onStopAll)
221        self.actionDeleteAll.triggered.connect(self._onDeleteAll)
222        self.actionLoad.triggered.connect(self._onLoad)
223        self.actionSave.triggered.connect(self._onSave)
224        vrFileIOService.projectLoaded.connect(self._onProjectLoaded)
225
226        # UI setup
227        self.actionAdd.setIcon(getIcon("CreateNew"))
228        self.actionStartAll.setIcon(getIcon("Run"))
229        self.actionStopAll.setIcon(getIcon("Stop"))
230        self.actionDeleteAll.setIcon(getIcon("Delete"))
231        self.actionLoad.setIcon(getIcon("FileOpen"))
232        self.actionSave.setIcon(getIcon("Save"))
233        self.QuickActionBar.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu);
234        self.updateUI();
235        
236    def _onAdd(self):
237        exeFile = QFileDialog.getOpenFileName(None, "Select Executable", "", "*.exe")[0]
238        if len(exeFile) > 0:
239            port = self.getAvailablePort()
240            self.addProcess(exeFile, port, self.autoStart.isChecked())
241
242    def _onLoad(self):
243        configFile = QFileDialog.getOpenFileName(None, "Load config file", "", "*.cfg")[0]
244        if len(configFile) > 0:
245            self.loadConfig(configFile)
246
247    def _onSave(self):
248        configFile = QFileDialog.getSaveFileName(None, "Save config file", self._getSuggestedFilename(), "*.cfg")[0]
249        if len(configFile) > 0:
250            self.saveConfig(configFile)
251
252    def _getSuggestedFilename(self):
253        # Suggest saving the config next to the current .vpb because it will be then 
254        # automatically loaded with the vpb. See _onProjectLoaded.
255        suggestedFilename = ".cfg"
256        vredFile = vrFileIOService.getFileName()
257        filepath, ext = os.path.splitext(vredFile)
258        if ext == ".vpb":
259            suggestedFilename = filepath + ".cfg"
260        else:
261            suggestedFilename = self.lastConfigFile
262        return suggestedFilename
263
264    def _onStartAll(self):
265        for id in sorted(self.processWidgets):
266            widget = self.processWidgets[id]
267            widget.process.startProcess()
268
269    def _onStopAll(self):
270        for id in sorted(self.processWidgets):
271            widget = self.processWidgets[id]
272            widget.process.stopProcess()
273
274    def _onDeleteAll(self):
275        if len(self.processWidgets) == 0:
276            return
277        # Ask for confirmation before deleting everything.
278        msgTitle = "QtQuickStreaming"
279        msgText = "Stop and delete all processes from the list?\nThis cannot be undone."
280        msgBox = QMessageBox(QMessageBox.Warning, msgTitle, msgText, QMessageBox.NoButton, self)
281        deleteButton = msgBox.addButton("Delete", QMessageBox.ActionRole)
282        cancelButton = msgBox.addButton(QMessageBox.Cancel)
283        msgBox.exec_()
284        if msgBox.clickedButton() == deleteButton:
285            self.deleteAllProcesses()
286
287    def _onProjectLoaded(self, file):
288        """ Look for a .cfg file with the same name next to the loaded .vpb file and load it. """
289        filepath, ext = os.path.splitext(file)
290        if ext == ".vpb":
291            configFile = filepath + ".cfg"
292            if os.path.exists(configFile):
293                print("Load config ", configFile)
294                self.loadConfig(configFile)
295
296    def _onProcessWidgetDeleted(self, id):
297        procWidget = self.processWidgets.pop(id, None)
298        self._deleteWidget(procWidget)
299        self._onProcessListChanged()
300
301    def _deleteWidget(self, procWidget):
302        if procWidget is not None:
303            procWidget.process.stopProcess()
304            self.processWidgetsLayout.removeWidget(procWidget)
305            procWidget.deleteLater()
306
307    def deleteAllProcesses(self):
308        for id, widget in list(self.processWidgets.items()):
309            self._deleteWidget(widget)
310        self.processWidgets = {}
311        self._onProcessListChanged()
312
313    def _onProcessListChanged(self):
314        self.updateUI()
315
316    def updateUI(self):
317        hasProcesses = len(self.processWidgets) > 0
318        self.actionSave.setEnabled(hasProcesses)
319        self.actionStartAll.setEnabled(hasProcesses)
320        self.actionStopAll.setEnabled(hasProcesses)
321        self.actionDeleteAll.setEnabled(hasProcesses)
322
323    def addProcess(self, name, port, doStart):
324        # create process
325        process = ProcessObject(name, port)
326        if doStart:
327            process.startProcess() 
328        # create widget for process
329        procWidget = ProcessWidget(self, process)
330        procWidget.deleteSignal.connect(self._onProcessWidgetDeleted)
331        self.processWidgets[procWidget.id] = procWidget
332        self.processWidgetsLayout.addWidget(procWidget)
333        self._onProcessListChanged()
334        
335    def getAvailablePort(self):
336        """ Search for a port that is not used yet and return its number. """
337        portRange = list(range(9000, 9100))
338        socket = QTcpSocket()
339        for p in portRange:
340            if not self.isPortAssignedToProcess(p):
341                free = socket.bind(p, QTcpSocket.DontShareAddress)
342                socket.close()
343                if free:
344                    return p
345        return 0
346
347    def isPortAssignedToProcess(self, port):
348        for id, widget in list(self.processWidgets.items()):
349            if port == widget.process.getPort():
350                return True
351        return False
352
353    def saveConfig(self, fileName):
354        """ Write config file as a pipe delimited text file. """
355        try:
356            with open(fileName, 'w') as openedFile:
357                for id in sorted(self.processWidgets):
358                    widget = self.processWidgets[id]
359                    path = widget.process.getPath()
360                    port = widget.process.getPort()
361                    running = int(widget.process.getRunningState())
362                    openedFile.write("{}|{}|{}\n".format(path, port, running))
363        except IOError as e:
364            vrLogError("Could not save {0}. I/O error({1}): {2}".format(fileName, e.errno, e.strerror))
365        except:
366            vrLogError("Could not save {0}. Unexpected error.".format(fileName))
367
368    def loadConfig(self, fileName):
369        if os.path.exists(fileName):
370            try:
371                with open(fileName, 'r') as openedFile:
372                    self.lastConfigFile = fileName
373                    self.deleteAllProcesses()
374                    for line in openedFile:
375                        processName, port, runningState = line.strip().split('|')
376                        if len(processName) > 0 and len(port) >0 and len(runningState) >0:
377                            doStart = bool(runningState) and self.autoStart.isChecked()
378                            self.addProcess(processName, int(port), doStart)
379            except IOError as e:
380                vrLogError("Could not load {0}. I/O error({1}): {2}".format(fileName, e.errno, e.strerror))
381            except:
382                vrLogError("Could not load {0}. Unexpected error.".format(fileName))
383
384
385def onDestroyVREDScriptPlugin():
386    """
387    onDestroyVREDScriptPlugin() is called before this plugin is destroyed. 
388    In this plugin we want to stop all processes.
389    """
390    streamingPlugin.deleteAllProcesses()
391
392
393# Create the plugin widget
394streamingPlugin = QtQuickStreaming(VREDPluginWidget)