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)