Browse Source

Attaching Argodejo to a running nsmd GUI, including --load-session, should work now. Maybe some corner cases, but this is a good basis for testing. FYI There is a known bug with our own nsm-data client that needs further work.

master
Nils 4 years ago
parent
commit
ad546321aa
  1. 29
      engine/api.py
  2. 217
      engine/nsmservercontrol.py
  3. 38
      engine/start.py
  4. 56
      qtgui/mainwindow.py
  5. 27
      qtgui/opensessioncontroller.py
  6. 39
      qtgui/quickopensessioncontroller.py
  7. 2
      qtgui/sessiontreecontroller.py

29
engine/api.py

@ -136,7 +136,7 @@ class Callbacks(object):
func(clientInfoDict) func(clientInfoDict)
def startEngine(): def startEngine():
logger.info("Start Engine") logger.info("Start Engine")
global eventLoop global eventLoop
@ -153,6 +153,7 @@ def startEngine():
dataClientDescriptionHook=callbacks._dataClientDescriptionChanged, dataClientDescriptionHook=callbacks._dataClientDescriptionChanged,
parameterNsmOSCUrl=PATHS["url"], parameterNsmOSCUrl=PATHS["url"],
sessionRoot=PATHS["sessionRoot"], sessionRoot=PATHS["sessionRoot"],
startupSession=PATHS["startupSession"],
) )
#Watch session tree for changes. #Watch session tree for changes.
@ -172,19 +173,31 @@ def startEngine():
#Send initial data #Send initial data
#The decision if we are already in a session on startup or in "choose a session mode" is handled by callbacks #The decision if we are already in a session on startup or in "choose a session mode" is handled by callbacks
#This is not to actually gather the data, but only to inform the GUI. #This is not to actually gather the data, but only to inform the GUI.
callbacks._sessionsChanged() logger.info("Send initial cached data to GUI.")
callbacks._sessionsChanged() #send session list
c = currentSession() #sessionName
if c:
callbacks._sessionOpenReady(nsmServerControl.sessionAsDict(c))
#Send client list. This is only necessary when attaching to an URL or using NSM-URL Env var
#When we do --load-session we receive client updates live.
#But this little redundancy doesn't hurt, we just sent them. Better safe than sorry.
for clientId, clientDict in nsmServerControl.internalState["clients"].items():
callbacks._clientStatusChanged(clientDict)
else:
callbacks._sessionClosed() #explicit is better than implicit. Otherwise a GUI might start in the wrong state
#nsmServerControl blocks until it has a connection to nsmd. That means at this point we are ready to send commands. #nsmServerControl blocks until it has a connection to nsmd. That means at this point we are ready to send commands.
#Until we return from startEngine a GUI will also not create its mainwindow. This can be used to create the appearance #Until we return from startEngine a GUI will also not create its mainwindow.
#of a session-on-load: logger.info("Engine start complete")
if PATHS["startupSession"]:
logger.info(f"Got start-session as command line parameter. Opening: {PATHS['startupSession']}")
sessionOpen(PATHS["startupSession"])
PATHS["continueLastSession"] = None #just in case
#Info #Info
def ourOwnServer():
"""Report if we started nsmd on our own. If not we will not kill it when we quit"""
return nsmServerControl.ourOwnServer
def sessionRoot(): def sessionRoot():
return nsmServerControl.sessionRoot return nsmServerControl.sessionRoot

217
engine/nsmservercontrol.py

@ -40,6 +40,9 @@ from uuid import uuid4
from datetime import datetime from datetime import datetime
from sys import exit as sysexit from sys import exit as sysexit
def nothing(*args, **kwargs):
pass
class _IncomingMessage(object): class _IncomingMessage(object):
"""Representation of a parsed datagram representing an OSC message. """Representation of a parsed datagram representing an OSC message.
@ -295,7 +298,8 @@ class NsmServerControl(object):
http://non.tuxfamily.org/nsm/API.html http://non.tuxfamily.org/nsm/API.html
""" """
def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook, parameterNsmOSCUrl=None, sessionRoot=None, useCallbacks=True): def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook,
parameterNsmOSCUrl=None, sessionRoot=None, startupSession=None, useCallbacks=True):
"""If useCallbacks is False you will see every message in the log. """If useCallbacks is False you will see every message in the log.
This is just a development mode to see all messages, unfiltered. This is just a development mode to see all messages, unfiltered.
@ -303,6 +307,10 @@ class NsmServerControl(object):
show in the logs""" show in the logs"""
#Deactivate hooks for now. During init no hooks may be called,
#but some functions want to do that already. We setup the true hooks at the end of init
self.sessionOpenReadyHook= self.sessionOpenLoadingHook= self.sessionClosedHook= self.clientStatusHook= self.singleInstanceActivateWindowHook= self.dataClientNamesHook= self.dataClientDescriptionHook= nothing
self._queue = list() #Incoming OSC messages are buffered here. self._queue = list() #Incoming OSC messages are buffered here.
#Status variables that are set by our callbacks #Status variables that are set by our callbacks
@ -318,16 +326,7 @@ class NsmServerControl(object):
} }
self.dataStorage = None #Becomes DataStorage() every time a datastorage client does a broadcast announce. self.dataStorage = None #Becomes DataStorage() every time a datastorage client does a broadcast announce.
self._addToNextSession = [] #A list of executables in PATH. Filled by new, waits for reply that session is created and then will send clientNew and clear the list. self._addToNextSession = [] #A list of executables in PATH. Filled by new, waits for reply that session is created and then will send clientNew and clear the list.
#Hooks for api callbacks
self.sessionOpenReadyHook = sessionOpenReadyHook #nsmSessionName as parameter
self.sessionOpenLoadingHook = sessionOpenLoadingHook #nsmSessionName as parameter
self.sessionClosedHook = sessionClosedHook #no parameter. This is also "choose a session" mode
self.clientStatusHook = clientStatusHook #all client status is done via this single hook. GUIs need to check if they already know the client or not.
self.dataClientNamesHook = dataClientNamesHook
self.dataClientDescriptionHook = dataClientDescriptionHook
self.singleInstanceActivateWindowHook = singleInstanceActivateWindowHook #added to self.processSingleInstance() to listen for a message from another wannabe-instance
if useCallbacks: if useCallbacks:
self.callbacks = { self.callbacks = {
@ -387,7 +386,7 @@ class NsmServerControl(object):
#No further action required. GUI announce below this testing. #No further action required. GUI announce below this testing.
pass pass
else: else:
self._startNsmdOurselves(sessionRoot) #Session root can be a commandline parameter we forward to the server if we start it ourselves. self._startNsmdOurselves(sessionRoot, startupSession) #Session root can be a commandline parameter we forward to the server if we start it ourselves. startupSession is an autoloader. Both are usually None.
assert type(self.ourOwnServer) is subprocess.Popen, (self.ourOwnServer, type(self.ourOwnServer)) assert type(self.ourOwnServer) is subprocess.Popen, (self.ourOwnServer, type(self.ourOwnServer))
#Wait for the server, or test if it is reacting. #Wait for the server, or test if it is reacting.
@ -398,9 +397,22 @@ class NsmServerControl(object):
self.sessionRoot = self._initial_announce() #Triggers "hi" and session root self.sessionRoot = self._initial_announce() #Triggers "hi" and session root
self.internalState["sessionRoot"] = self.sessionRoot self.internalState["sessionRoot"] = self.sessionRoot
self._forceProcessOnceToEmptyQueue() #process any leftover messages.
atexit.register(self.quit) #mostly does stuff when we started nsmd ourself atexit.register(self.quit) #mostly does stuff when we started nsmd ourself
#Activate hooks for api callbacks, now that we are finished here.
#Otherwise the hooks will get called from our functions (e.g. new client) while we are still during init
self.sessionOpenReadyHook = sessionOpenReadyHook #self.sessionAsDict(nsmSessionName) as parameter
self.sessionOpenLoadingHook = sessionOpenLoadingHook #self.sessionAsDict(nsmSessionName) as parameter
self.sessionClosedHook = sessionClosedHook #no parameter. This is also "choose a session" mode
self.clientStatusHook = clientStatusHook #all client status is done via this single hook. GUIs need to check if they already know the client or not.
self.dataClientNamesHook = dataClientNamesHook
self.dataClientDescriptionHook = dataClientDescriptionHook
self.singleInstanceActivateWindowHook = singleInstanceActivateWindowHook #added to self.processSingleInstance() to listen for a message from another wannabe-instance
self._receiverActive = True self._receiverActive = True
logger.info("nsmservercontrol init is complete. Ready for event loop")
#Now an external event loop can add self.process #Now an external event loop can add self.process
#Internal Methods #Internal Methods
@ -448,7 +460,7 @@ class NsmServerControl(object):
"""Set both the socket and the thread into waiting mode or not. """Set both the socket and the thread into waiting mode or not.
With this we can wait for answers until we resume async operation""" With this we can wait for answers until we resume async operation"""
if state: if state:
self.sock.setblocking(True) #explicitly wait. self.sock.setblocking(True) #explicitly wait.
self.sock.settimeout(0.5) self.sock.settimeout(0.5)
self._receiverActive = False self._receiverActive = False
logger.info("Suspending receiving async mode.") logger.info("Suspending receiving async mode.")
@ -457,6 +469,41 @@ class NsmServerControl(object):
self._receiverActive = True self._receiverActive = True
logger.info("Resuming receiving async mode.") logger.info("Resuming receiving async mode.")
def _forceProcessOnceToEmptyQueue(self):
"""Sometimes we want to make sure everything is processed until we continue. For example
in our init.
Initial usecase was connecting to a running nsmd with session. The api first callback to
export sessions to the GUI was freezing because listSession was chocking on leftover messages
from gui_announce to a running session, which sends the session name and a list of clients.
The latter is not happening when starting the server ourselves, so we weren't expecting this.
To be honest, this is really a patch to work around a design flaw and we hope this is a
one-off corner case."""
logger.info("Force processing queue")
#First gather all osc messages still in the pipe
while True:
try:
data, addr = self.sock.recvfrom(1024)
msg = _IncomingMessage(data)
if msg:
self._queue.append(msg)
except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not.
break
except socket.timeout:
break
#Now process them all. This is different than normal self.process().
for msg in self._queue:
if msg.oscpath in self.callbacks:
self.callbacks[msg.oscpath](msg.params)
else:
logger.warning(f"Unhandled message with path {msg.oscpath} and parameters {msg.params}")
self._queue.clear()
logger.info("Ended force processing queue")
def process(self): def process(self):
"""Use this in an external event loop""" """Use this in an external event loop"""
if self._receiverActive: if self._receiverActive:
@ -513,29 +560,36 @@ class NsmServerControl(object):
finally: finally:
tempServerSock.close() tempServerSock.close()
def _startNsmdOurselves(self, sessionRoot:str): def _startNsmdOurselves(self, sessionRoot:str, startupSession:str):
assert self.nsmOSCUrl assert self.nsmOSCUrl
hostname, port = self.nsmOSCUrl hostname, port = self.nsmOSCUrl
arguments = ["nsmd","--osc-port", str(port)]
if sessionRoot: if sessionRoot:
self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port), "--session-root", sessionRoot]) arguments += ["--session-root", sessionRoot]
else:
self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port)]) if startupSession:
logger.info(f"Got start-session as command line parameter. Fowarding to nsmd command line: {startupSession}")
arguments += ["--load-session", startupSession]
self.ourOwnServer = subprocess.Popen(arguments)
def _blockingRequest(self, path:str, arguments:list, answerPath:str, answerArguments:list, repeat=False)->list: def _blockingRequest(self, path:str, arguments:list, answerPath:str, answerArguments:list, repeat=False)->list:
"""During start-up we need to wait for replies. Also some operations only make sense """During start-up we need to wait for replies. Also some operations only make sense
if we got data back. This is an abstraction that deals with messages that may come if we got data back. This is an abstraction that deals with messages that may come
out-of-order and keeps them for later, but at least prevents our side from sending out-of-order and keeps them for later, but at least prevents our side from sending
messages out-of-order itself. messages out-of-order itself.
Default is: send once, wait for answer. repeat=True sends multiple times until an answer arrives. Default is: send once, wait for answer. repeat=True sends multiple times until an answer arrives.
Returns list of arguments, can be empty. Returns list of arguments, can be empty.
""" """
assert not self._queue, [(m.oscpath, m.params) for m in self._queue] assert not self._queue, [(m.oscpath, m.params) for m in self._queue]
logger.info(f"[wait for answer]: Sending {path}: {arguments}") logger.info(f"[wait for answer]: Sending {path}: {arguments}")
self._setPause(True) self._setPause(True)
out_msg = _OutgoingMessage(path) out_msg = _OutgoingMessage(path)
for arg in arguments: for arg in arguments:
out_msg.add_arg(arg) out_msg.add_arg(arg)
@ -547,19 +601,19 @@ class NsmServerControl(object):
ready = False ready = False
while not ready: while not ready:
if repeat: #we need to send multiple times. if repeat: #we need to send multiple times.
self.sock.sendto(out_msg.build(), self.nsmOSCUrl) self.sock.sendto(out_msg.build(), self.nsmOSCUrl)
try: try:
data, addr = self.sock.recvfrom(1024) data, addr = self.sock.recvfrom(1024)
msg = _IncomingMessage(data) msg = _IncomingMessage(data)
if answerArguments and msg.oscpath == answerPath and msg.params == answerArguments: if answerArguments and msg.oscpath == answerPath and msg.params == answerArguments:
result = msg.params result = msg.params
logger.info(f"[wait from {path}] Received {answerPath}: {result}") logger.info(f"[wait from {path}] Received {answerPath}: {result}")
ready = True ready = True
elif msg.oscpath == answerPath: elif msg.oscpath == answerPath:
result = msg.params result = msg.params
logger.info(f"[wait from {path}] Received {answerPath}: {result}") logger.info(f"[wait from {path}] Received {answerPath}: {result}")
ready = True ready = True
else: else:
logger.warning(f"Waiting for {answerPath} from nsmd, but got: {msg.oscpath} with {msg.params}. Adding to queue for later.") logger.warning(f"Waiting for {answerPath} from nsmd, but got: {msg.oscpath} with {msg.params}. Adding to queue for later.")
self._queue.append(msg) self._queue.append(msg)
except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not. except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not.
@ -578,14 +632,18 @@ class NsmServerControl(object):
"""nsm/gui/gui_announce triggers a multi-stage reply. First we get "hi", """nsm/gui/gui_announce triggers a multi-stage reply. First we get "hi",
then we get the session root. We wait for session root and then clean 'hi' from the queue. then we get the session root. We wait for session root and then clean 'hi' from the queue.
Returns session root as pathlib-path.""" When we connect to a running nsmd we also receive /nsm/gui/session/name with the current
session (or empty string for no current).
If in a session we will receive a list of clients which ends the gui_announce stage.
Returns session root as pathlib-path."""
resultArguments = self._blockingRequest(path="/nsm/gui/gui_announce", arguments=[], answerPath="/nsm/gui/session/root", answerArguments=[]) resultArguments = self._blockingRequest(path="/nsm/gui/gui_announce", arguments=[], answerPath="/nsm/gui/session/root", answerArguments=[])
if len(self._queue) == 1 and self._queue[0].oscpath == "/nsm/gui/gui_announce" and self._queue[0].params == ["hi"]: if len(self._queue) == 1 and self._queue[0].oscpath == "/nsm/gui/gui_announce" and self._queue[0].params == ["hi"]:
logger.info("Got 'hi'. We are now the registered nsmd GUI as per our initial /nsm/gui/gui_announce") logger.info("Got 'hi'. We are now the registered nsmd GUI as per our initial /nsm/gui/gui_announce")
self._queue.clear() self._queue.clear() #this is safe because we tested above that there is exactly the hi message in the queue.
else: else:
logging.error(f"For ValueError below: {[(m.oscpath, m.params) for m in self._queue]}") logging.error(f"For ValueError below: {[(m.oscpath, m.params) for m in self._queue]}")
raise ValueError("We were expecting a clean _queue with only 'hi' as leftover, but instead there were unhandled messages. see print above. Better abort than a wrong program state") raise ValueError("We were expecting a clean _queue with only 'hi' as leftover, but instead there were unhandled messages. see print above. Better abort than a wrong program state")
#all ok #all ok
return pathlib.Path(resultArguments[0]) return pathlib.Path(resultArguments[0])
@ -604,7 +662,7 @@ class NsmServerControl(object):
def gui_announce(self): def gui_announce(self):
"""This is just the announce without any answer. This is a last-resort method if another GUI """This is just the announce without any answer. This is a last-resort method if another GUI
"stole" our slot. For our own initial announce we use self._initial_announce()""" "stole" our slot. For our own initial announce we use self._initial_announce()"""
msg = _OutgoingMessage("/nsm/gui/gui_announce") msg = _OutgoingMessage("/nsm/gui/gui_announce")
self.sock.sendto(msg.build(), self.nsmOSCUrl) self.sock.sendto(msg.build(), self.nsmOSCUrl)
@ -621,12 +679,19 @@ class NsmServerControl(object):
like copy, rename and delete. like copy, rename and delete.
Ask nsmd for projects in session root and update our internal state. Ask nsmd for projects in session root and update our internal state.
This will return None without doing anything when we are already in a session.
This will wait for an answer and block all other operations. This will wait for an answer and block all other operations.
First is /nsm/gui/server/message ['Listing sessions'] First is /nsm/gui/server/message ['Listing sessions']
Then session names come one reply at a time such as /reply ['/nsm/server/list', 'test3'] Then session names come one reply at a time such as /reply ['/nsm/server/list', 'test3']
Finally /nsm/server/list [0, 'Done.'] , not a reply Finally /nsm/server/list [0, 'Done.'] , not a reply
""" """
#In the past we only regenerated if we are not in a session. However, that was overzealous.
#Some GUI functions did not work. Better regenerate that list as often as we want.
logger.info("Requesting project list from session server in blocking mode") logger.info("Requesting project list from session server in blocking mode")
self._setPause(True) self._setPause(True)
@ -650,7 +715,7 @@ class NsmServerControl(object):
self._queue.append(msg) self._queue.append(msg)
continue continue
#This is what we want: #This is what we want:
elif msg.oscpath == "/reply" and msg.params[0] == "/nsm/server/list": elif msg.oscpath == "/reply" and msg.params[0] == "/nsm/server/list":
#/reply ['/nsm/server/list', 'test3'] for a real session or #/reply ['/nsm/server/list', 'test3'] for a real session or
#/reply ['/nsm/server/list', ''] as "list ended" marker #/reply ['/nsm/server/list', ''] as "list ended" marker
if msg.params[1]: if msg.params[1]:
@ -694,12 +759,12 @@ class NsmServerControl(object):
self.sock.sendto(message.build(), self.nsmOSCUrl) self.sock.sendto(message.build(), self.nsmOSCUrl)
#Primarily Without Session #Primarily Without Session
def open(self, nsmSessionName:str): def open(self, nsmSessionName:str):
if nsmSessionName in self.internalState["sessions"]: if nsmSessionName in self.internalState["sessions"]:
msg = _OutgoingMessage("/nsm/server/open") msg = _OutgoingMessage("/nsm/server/open")
msg.add_arg(nsmSessionName) #s:project_name msg.add_arg(nsmSessionName) #s:project_name
self.sock.sendto(msg.build(), self.nsmOSCUrl) self.sock.sendto(msg.build(), self.nsmOSCUrl)
else: else:
logger.warning(f"Session {nsmSessionName} not found. Not forwarding to nsmd.") logger.warning(f"Session {nsmSessionName} not found. Not forwarding to nsmd.")
def new(self, newName:str, startClients:list=[])->str: def new(self, newName:str, startClients:list=[])->str:
@ -708,7 +773,7 @@ class NsmServerControl(object):
""" """
basePath = pathlib.Path(self.sessionRoot, newName) basePath = pathlib.Path(self.sessionRoot, newName)
if basePath.exists(): if basePath.exists():
return None return None
self._addToNextSession = startClients self._addToNextSession = startClients
@ -760,6 +825,7 @@ class NsmServerControl(object):
We do not trust NSM to perform the right checks. It will add an empty path or wrong We do not trust NSM to perform the right checks. It will add an empty path or wrong
path. path.
""" """
if not pathlib.Path(executableName).name == executableName: if not pathlib.Path(executableName).name == executableName:
logger.warning(f"{executableName} must be just an executable file in your $PATH. We expected: {pathlib.Path(executableName).name} . We will not ask nsmd to add it as client") logger.warning(f"{executableName} must be just an executable file in your $PATH. We expected: {pathlib.Path(executableName).name} . We will not ask nsmd to add it as client")
return False return False
@ -790,7 +856,11 @@ class NsmServerControl(object):
def clientRemove(self, clientId:str): def clientRemove(self, clientId:str):
"""Client needs to be stopped already. We will do that and wait for an answer. """Client needs to be stopped already. We will do that and wait for an answer.
Remove from the session. Will not delete the save-files, but make them inaccesible""" Remove from the session. Will not delete the save-files, but make them inaccesible.
There is never a point in nsmservercontrol where self.internalState["clients"] is emptied.
nsmd actually sends a clientRemove for every client at session stop.
"""
#We have a blocking operation in here so we need to be extra cautios that the client exists. #We have a blocking operation in here so we need to be extra cautios that the client exists.
if not clientId in self.internalState["clients"]: if not clientId in self.internalState["clients"]:
return False return False
@ -914,6 +984,11 @@ class NsmServerControl(object):
parameters[0] is an osc path:str without naming constraints parameters[0] is an osc path:str without naming constraints
the rest is a list of arguments. the rest is a list of arguments.
Attention: a broadcast is not saved by the server. You either are in the session to receive
it or you will miss it. If we run Argodejo as attached GUI (incl. --load-session) a broadcast
after the session was loaded, where programs announce themselves to all other clients,
will not be received here. Such is the case with our data-client.
""" """
logger.info(f"Received broadcast. Saving in internal state: {parameters}") logger.info(f"Received broadcast. Saving in internal state: {parameters}")
self.internalState["broadcasts"].append(parameters) self.internalState["broadcasts"].append(parameters)
@ -935,19 +1010,19 @@ class NsmServerControl(object):
errorCode, message = parameters errorCode, message = parameters
assert errorCode == 0, errorCode assert errorCode == 0, errorCode
assert message == "Done.", message #don't miss the dot after Done assert message == "Done.", message #don't miss the dot after Done
logger.info("/nsm/server/list is done and has transmitted all available sessions to us") logger.info("/nsm/server/list is done and has transmitted all available sessions to us")
else: else:
raise NotImplementedError(parameters) raise NotImplementedError(parameters)
def _reactCallback_activeSessionChanged(self, parameters:list): def _reactCallback_activeSessionChanged(self, parameters:list):
"""We receive this trough /nsm/gui/session/name """We receive this trough /nsm/gui/session/name
This is called when the session has already changed. This is called when the session has already changed.
This also happens when you connect to a headless nsmd with a running session. This also happens when you connect to a headless nsmd with a running session.
We expect two parameters: [session name, session path] both of which could be "". We expect two parameters: [session name, session path] both of which could be "".
If we start nsmd ourselves into an empty state we expect session name to be empty If we start nsmd ourselves into an empty state we expect session name to be empty
Shortly before we received /nsm/gui/session/session which indicates the attempt to create a Shortly before we received /nsm/gui/session/session which indicates the attempt to create a
new one, I guess! :) new one, I guess! :)
@ -962,7 +1037,7 @@ class NsmServerControl(object):
nsmSessionName, sessionPath = parameters nsmSessionName, sessionPath = parameters
if not nsmSessionName and not sessionPath: #No session loaded. We are in session-choosing mode. if not nsmSessionName and not sessionPath: #No session loaded. We are in session-choosing mode.
logger.info("Session closed or never started. Choose-A-Session mode.") logger.info("Session closed or never started. Choose-A-Session mode.")
self.internalState["currentSession"] = None self.internalState["currentSession"] = None #sessionCloseHooked triggers rebuilding of the session list, which will not work when there is a current session.
self.sessionClosedHook() self.sessionClosedHook()
else: else:
sessionPath = sessionPath.lstrip("/") sessionPath = sessionPath.lstrip("/")
@ -972,24 +1047,32 @@ class NsmServerControl(object):
#We have a counterpart-message reaction that signals the attempt to load. #We have a counterpart-message reaction that signals the attempt to load.
self.sessionOpenReadyHook(self.sessionAsDict(nsmSessionName)) #notify the api->UI self.sessionOpenReadyHook(self.sessionAsDict(nsmSessionName)) #notify the api->UI
for autoClientExecutableInPath in self._addToNextSession: for autoClientExecutableInPath in self._addToNextSession:
self.clientAdd(autoClientExecutableInPath) self.clientAdd(autoClientExecutableInPath)
self._addToNextSession = [] #reset self._addToNextSession = [] #reset
elif l == 0: #Another way of "no session". elif l == 0: #Another way of "no session".
self.internalState["currentSession"] = None self.internalState["currentSession"] = None #sessionCloseHooked triggers rebuilding of the session list, which will not work when there is a current session.
self.sessionClosedHook() self.sessionClosedHook()
else: else:
raise NotImplementedError(parameters) raise NotImplementedError(parameters)
def _initializeEmptyClient(self, clientId:str): def _initializeEmptyClient(self, clientId:str):
"""NSM reuses signals. It is quite possible that this will be called multiple times, """NSM reuses signals. It is quite possible that this will be called multiple times,
e.g. after opening a session""" e.g. after opening a session.
This is not a reaction callback, we call this ourselves only in _reactCallback_ClientNew
"""
#if not self.internalState["currentSession"]:
# logger.warning(f"We received a clientNew for ID {clientId} but no session open was received."
# "This would happen in an old nsmd version. If you see the GUI with an open session and a client list you can ignore this warning")
if clientId in self.internalState["clients"]: if clientId in self.internalState["clients"]:
return return
logger.info(f"Creating new internal entry for client {clientId}") logger.info(f"Creating new internal entry for client {clientId}")
client = { client = {
"clientId":clientId, #for convenience, included internally as well "clientId":clientId, #for convenience, included internally as well
"executable":None, #For dumb clients this is the same as reportedName.
"dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this. "dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this.
"executable":None, #Every client announces to the GUI with the exectuable name. True nsm clients later overwrite with a pretty name which we save as "reportedName"
"reportedName":None, #str . The reported name is first the executable name, for status started. But for NSM clients it gets replaced with a reported name. "reportedName":None, #str . The reported name is first the executable name, for status started. But for NSM clients it gets replaced with a reported name.
"label":None, #str "label":None, #str
"lastStatus":None, #str "lastStatus":None, #str
@ -1010,7 +1093,7 @@ class NsmServerControl(object):
def _reactCallback_ClientLabelChanged(self, parameters:list): def _reactCallback_ClientLabelChanged(self, parameters:list):
"""osc->add_method( "/nsm/gui/client/label", "ss", osc_handler, osc, "path,display_name" ); """osc->add_method( "/nsm/gui/client/label", "ss", osc_handler, osc, "path,display_name" );
""" """
l = len(parameters) l = len(parameters)
if l == 2: if l == 2:
clientId, label = parameters clientId, label = parameters
@ -1035,25 +1118,25 @@ class NsmServerControl(object):
def _reactCallback_ClientNew(self, parameters:list): def _reactCallback_ClientNew(self, parameters:list):
"""/nsm/gui/client/new ['nBAVO', 'jackpatch'] """/nsm/gui/client/new ['nBAVO', 'jackpatch']
This is both add client or open. This is both add client or open.
It appears in the session.
And the message actually comes twice. Once when you add a client, then parameters The message comes twice. Once when you add a client, then parameters
will contain the executable name. If the client reports itself as NSM compatible through will contain the executable name. If the client reports itself as NSM compatible through
announce we will also get the Open message through this function. announce we will also get the Open message through this function.
Then the name changes from executableName to a reportedName, which will remain for the rest Then the name changes from executableName to a reportedName, which will remain for the rest
of the session. of the session. Executable name is still important to look up icons in the GUI.
A loaded session will directly start with the reported/announced name.
This message is usually followed by /nsm/gui/client/status
""" """
l = len(parameters) l = len(parameters)
if l == 2: if l == 2:
clientId, executableName = parameters clientId, name = parameters
if not clientId in self.internalState["clients"]: if not clientId in self.internalState["clients"]:
self._initializeEmptyClient(clientId) self._initializeEmptyClient(clientId)
self._setClientData(clientId, "executable", executableName) self._setClientData(clientId, "executable", name)
logger.info(f"Client started {executableName}:{clientId}") logger.info(f"Client started {name}:{clientId}")
else: else:
self._setClientData(clientId, "reportedName", executableName) self._setClientData(clientId, "reportedName", name)
logger.info(f"Client upgraded to NSM-compatible: {executableName}:{clientId}") logger.info(f"Client upgraded to NSM-compatible: {name}:{clientId}")
self.clientStatusHook(self.internalState["clients"][clientId]) self.clientStatusHook(self.internalState["clients"][clientId])
else: else:
raise NotImplementedError(parameters) raise NotImplementedError(parameters)
@ -1124,7 +1207,7 @@ class NsmServerControl(object):
if status == "ready": #we need to check for this now. Below in actions is after the statusHook and too late. if status == "ready": #we need to check for this now. Below in actions is after the statusHook and too late.
self._setClientData(clientId, "dumbClient", False) self._setClientData(clientId, "dumbClient", False)
self.clientStatusHook(self.internalState["clients"][clientId]) self.clientStatusHook(self.internalState["clients"][clientId])
else: else:
raise NotImplementedError(parameters) raise NotImplementedError(parameters)
@ -1186,7 +1269,7 @@ class NsmServerControl(object):
after start by the GUI. after start by the GUI.
""" """
pass pass
def _reactStatus_save(self, clientId:str): def _reactStatus_save(self, clientId:str):
""" """
@ -1376,11 +1459,11 @@ class NsmServerControl(object):
logger.warning(f"Can't rename {nsmSessionName} to {newName}. {newName} already exists.") logger.warning(f"Can't rename {nsmSessionName} to {newName}. {newName} already exists.")
return False return False
else: else:
logger.info(f"Renaming {nsmSessionName} to {newName}.") logger.info(f"Renaming {nsmSessionName} to {newName}.")
tmp = pathlib.Path(oldPath.name+str(uuid4())) #Can't move itself into a subdir in itself. move to temp first. We don't use tempdir because that could be on another partition. we already know we can write here. tmp = pathlib.Path(oldPath.name+str(uuid4())) #Can't move itself into a subdir in itself. move to temp first. We don't use tempdir because that could be on another partition. we already know we can write here.
oldPath.rename(tmp) oldPath.rename(tmp)
pathlib.Path(newPath).mkdir(parents=True, exist_ok=True) pathlib.Path(newPath).mkdir(parents=True, exist_ok=True)
tmp.rename(newPath) tmp.rename(newPath)
assert newPath.exists() assert newPath.exists()
def copySession(self, nsmSessionName:str, newName:str): def copySession(self, nsmSessionName:str, newName:str):
@ -1421,7 +1504,6 @@ class NsmServerControl(object):
if not sessionFile.exists(): if not sessionFile.exists():
#This is a reason to let the program exit. #This is a reason to let the program exit.
print (nsmSessionName)
logger.error("Got wrong session directory from nsmd. Race condition after delete? In any case a breaking error (please report). Quitting. Project was: " + repr(sessionFile)) logger.error("Got wrong session directory from nsmd. Race condition after delete? In any case a breaking error (please report). Quitting. Project was: " + repr(sessionFile))
sysexit() #return None switch to return None to let it crash and see the python traceback sysexit() #return None switch to return None to let it crash and see the python traceback
@ -1430,7 +1512,7 @@ class NsmServerControl(object):
entry["sessionFile"] = sessionFile entry["sessionFile"] = sessionFile
entry["lockFile"] = pathlib.Path(basePath, ".lock") entry["lockFile"] = pathlib.Path(basePath, ".lock")
entry["fullPath"] = str(basePath) entry["fullPath"] = str(basePath)
entry["sizeInBytes"] = sum(f.stat().st_size for f in basePath.glob('**/*') if f.is_file() ) entry["sizeInBytes"] = sum(f.stat().st_size for f in basePath.glob('**/*') if f.is_file() )
entry["numberOfClients"] = len(open(sessionFile).readlines()) entry["numberOfClients"] = len(open(sessionFile).readlines())
entry["hasSymlinks"] = self._checkDirectoryForSymlinks(basePath) entry["hasSymlinks"] = self._checkDirectoryForSymlinks(basePath)
entry["parents"] = basePath.relative_to(self.sessionRoot).parts[:-1] #tuple of each dir between NSM root and nsmSessionName/session.nsm, exluding the actual project name. This is the tree entry["parents"] = basePath.relative_to(self.sessionRoot).parts[:-1] #tuple of each dir between NSM root and nsmSessionName/session.nsm, exluding the actual project name. This is the tree
@ -1440,7 +1522,10 @@ class NsmServerControl(object):
def exportSessionsAsDicts(self)->list: def exportSessionsAsDicts(self)->list:
"""Return a list of dicts of projects with additional information: """Return a list of dicts of projects with additional information:
""" """
logger.info("Exporting sessions to dict. Will call blocking list sessions next")
results = [] results = []
#assert not self.internalState["currentSession"], self.internalState["currentSession"] #Do not request session list while in active session
self._updateSessionListBlocking() self._updateSessionListBlocking()
for nsmSessionName in self.internalState["sessions"]: for nsmSessionName in self.internalState["sessions"]:
result = self.sessionAsDict(nsmSessionName) result = self.sessionAsDict(nsmSessionName)
@ -1484,7 +1569,7 @@ class DataStorage(object):
def descriptionToParentAndCallbacks(self): def descriptionToParentAndCallbacks(self):
"""Every char!!!""" """Every char!!!"""
self.parent.dataClientDescriptionHook(self.data["description"]) self.parent.dataClientDescriptionHook(self.data["description"])
def _waitForMultipartMessage(self, pOscpath:str)->str: def _waitForMultipartMessage(self, pOscpath:str)->str:
"""Returns a json string, as if the message was sent as a single one. """Returns a json string, as if the message was sent as a single one.
@ -1501,7 +1586,7 @@ class DataStorage(object):
data, addr = self.sock.recvfrom(1024) data, addr = self.sock.recvfrom(1024)
except socket.timeout: except socket.timeout:
break break
msg = _IncomingMessage(data) msg = _IncomingMessage(data)
if msg.oscpath == pOscpath: if msg.oscpath == pOscpath:
currentPartNumber, l, jsonChunk = msg.params currentPartNumber, l, jsonChunk = msg.params
@ -1546,7 +1631,7 @@ class DataStorage(object):
except socket.timeout: except socket.timeout:
break break
msg = _IncomingMessage(data) msg = _IncomingMessage(data)
if msg.oscpath == "/argodejo/datastorage/reply/getclient": if msg.oscpath == "/argodejo/datastorage/reply/getclient":
replyClientId, jsonName = msg.params replyClientId, jsonName = msg.params
assert replyClientId == clientId, (replyClientId, clientId) assert replyClientId == clientId, (replyClientId, clientId)
@ -1558,7 +1643,7 @@ class DataStorage(object):
answer = json.loads(jsonName) answer = json.loads(jsonName)
if answer: if answer:
self.data["clientOverrideNames"][clientId] = answer self.data["clientOverrideNames"][clientId] = answer
else: else:
#It is possible that a client not present in our storage will send an empty string. Protect. #It is possible that a client not present in our storage will send an empty string. Protect.
if clientId in self.data["clientOverrideNames"]: if clientId in self.data["clientOverrideNames"]:
del self.data["clientOverrideNames"][clientId] del self.data["clientOverrideNames"][clientId]

38
engine/start.py

@ -48,7 +48,7 @@ parser.add_argument("-V", "--verbose", action='store_true', help="(Development)
parser.add_argument("-u", "--url", action='store', dest="url", help="Force URL for the session. If there is already a running session we will connect to it. Otherwise we will start one there. Default is local host with random port. Example: osc.udp://myhost.localdomain:14294/") parser.add_argument("-u", "--url", action='store', dest="url", help="Force URL for the session. If there is already a running session we will connect to it. Otherwise we will start one there. Default is local host with random port. Example: osc.udp://myhost.localdomain:14294/")
parser.add_argument("--nsm-url", action='store', dest="url", help="Same as --url.") parser.add_argument("--nsm-url", action='store', dest="url", help="Same as --url.")
parser.add_argument("-s", "--session", action='store', dest="session", help="Session to open on startup. Overrides --continue") parser.add_argument("-l", "--load-session", action='store', dest="session", help="Session to open on startup, must exist. Overrides --continue")
parser.add_argument("-c", "--continue", action='store_true', dest="continueLastSession", help="Autostart last active session.") parser.add_argument("-c", "--continue", action='store_true', dest="continueLastSession", help="Autostart last active session.")
parser.add_argument("-i", "--hide", action='store_true', dest="starthidden", help="Start GUI hidden in tray, only if tray available on system.") parser.add_argument("-i", "--hide", action='store_true', dest="starthidden", help="Start GUI hidden in tray, only if tray available on system.")
parser.add_argument("--session-root", action='store', dest="sessionRoot", help="Root directory of all sessions. Defaults to '$HOME/NSM Sessions'") parser.add_argument("--session-root", action='store', dest="sessionRoot", help="Root directory of all sessions. Defaults to '$HOME/NSM Sessions'")
@ -88,11 +88,11 @@ try:
compiledVersion = True compiledVersion = True
logger.info("Compiled prefix found: {}".format(prefix)) logger.info("Compiled prefix found: {}".format(prefix))
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
compiledVersion = False compiledVersion = False
logger.info("Compiled version: {}".format(compiledVersion)) logger.info("Compiled version: {}".format(compiledVersion))
if compiledVersion: if compiledVersion:
PATHS={ #this gets imported PATHS={ #this gets imported
"root": "", "root": "",
"bin": os.path.join(prefix, "bin"), "bin": os.path.join(prefix, "bin"),
@ -100,15 +100,15 @@ if compiledVersion:
"desktopfile": os.path.join(prefix, "share", "applications", METADATA["shortName"] + ".desktop"), #not ~/Desktop but our desktop file "desktopfile": os.path.join(prefix, "share", "applications", METADATA["shortName"] + ".desktop"), #not ~/Desktop but our desktop file
"share": os.path.join(prefix, "share", METADATA["shortName"]), "share": os.path.join(prefix, "share", METADATA["shortName"]),
"templateShare": os.path.join(prefix, "share", METADATA["shortName"], "template"), "templateShare": os.path.join(prefix, "share", METADATA["shortName"], "template"),
"sessionRoot": args.sessionRoot, "sessionRoot": args.sessionRoot,
"url": args.url, "url": args.url,
"startupSession": args.session, "startupSession": args.session,
"startHidden": args.starthidden, #bool "startHidden": args.starthidden, #bool
"continueLastSession": args.continueLastSession, #bool "continueLastSession": args.continueLastSession, #bool
} }
_root = os.path.dirname(__file__) _root = os.path.dirname(__file__)
_root = os.path.abspath(os.path.join(_root, "..")) _root = os.path.abspath(os.path.join(_root, ".."))
else: else:
_root = os.path.dirname(__file__) _root = os.path.dirname(__file__)
_root = os.path.abspath(os.path.join(_root, "..")) _root = os.path.abspath(os.path.join(_root, ".."))
@ -119,13 +119,17 @@ else:
"desktopfile": os.path.join(_root, "desktop", "desktop.desktop"), #not ~/Desktop but our desktop file "desktopfile": os.path.join(_root, "desktop", "desktop.desktop"), #not ~/Desktop but our desktop file
"share": os.path.join(_root, "engine", "resources"), "share": os.path.join(_root, "engine", "resources"),
"templateShare": os.path.join(_root, "template", "engine", "resources"), "templateShare": os.path.join(_root, "template", "engine", "resources"),
"sessionRoot": args.sessionRoot, "sessionRoot": args.sessionRoot,
"url": args.url, "url": args.url,
"startupSession": args.session, "startupSession": args.session,
"startHidden": args.starthidden, #bool "startHidden": args.starthidden, #bool
"continueLastSession": args.continueLastSession, #bool "continueLastSession": args.continueLastSession, #bool
} }
if PATHS["startupSession"]:
logger.warning("--continue ignored because --load-session was used.")
PATHS["continueLastSession"] = None #just in case. See --help string
logger.info("PATHS: {}".format(PATHS)) logger.info("PATHS: {}".format(PATHS))
@ -144,8 +148,8 @@ def exitWithMessage(message:str):
sys.exit(title + ": " + message) sys.exit(title + ": " + message)
else: else:
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
#This is the start file for the Qt client so we know at least that Qt is installed and use that for a warning. #This is the start file for the Qt client so we know at least that Qt is installed and use that for a warning.
QMessageBox.critical(qtApp.desktop(), title, message) QMessageBox.critical(qtApp.desktop(), title, message)
sys.exit(title + ": " + message) sys.exit(title + ": " + message)
def setProcessName(executableName): def setProcessName(executableName):
@ -168,7 +172,7 @@ def setProcessName(executableName):
libpthread_path = ctypes.util.find_library("pthread") libpthread_path = ctypes.util.find_library("pthread")
if not libpthread_path: if not libpthread_path:
return return
libpthread = ctypes.CDLL(libpthread_path) libpthread = ctypes.CDLL(libpthread_path)
if hasattr(libpthread, "pthread_setname_np"): if hasattr(libpthread, "pthread_setname_np"):
_pthread_setname_np = libpthread.pthread_setname_np _pthread_setname_np = libpthread.pthread_setname_np
@ -182,9 +186,9 @@ def setProcessName(executableName):
if _pthread_setname_np is None: if _pthread_setname_np is None:
return return
_pthread_setname_np(_pthread_self(), executableName.encode()) _pthread_setname_np(_pthread_self(), executableName.encode())
def _is_jack_running(): def _is_jack_running():
"""Check for JACK""" """Check for JACK"""
@ -227,12 +231,12 @@ except:
#Catch Exceptions even if PyQt crashes. #Catch Exceptions even if PyQt crashes.
import sys import sys
sys._excepthook = sys.excepthook sys._excepthook = sys.excepthook
def exception_hook(exctype, value, traceback): def exception_hook(exctype, value, traceback):
"""This hook purely exists to call sys.exit(1) even on a Qt crash """This hook purely exists to call sys.exit(1) even on a Qt crash
so that atexit gets triggered""" so that atexit gets triggered"""
#print(exctype, value, traceback) #print(exctype, value, traceback)
logger.error("Caught crash in execpthook. Trying too execute atexit anyway") logger.error("Caught crash in execpthook. Trying too execute atexit anyway")
sys._excepthook(exctype, value, traceback) sys._excepthook(exctype, value, traceback)
sys.exit(1) sys.exit(1)
sys.excepthook = exception_hook sys.excepthook = exception_hook

56
qtgui/mainwindow.py

@ -116,17 +116,17 @@ class MainWindow(QtWidgets.QMainWindow):
self.qtApp.setWindowIcon(QtGui.QIcon(":icon.png")) #non-template part of the program self.qtApp.setWindowIcon(QtGui.QIcon(":icon.png")) #non-template part of the program
self.qtApp.setApplicationName(f"{METADATA['name']}") self.qtApp.setApplicationName(f"{METADATA['name']}")
self.qtApp.setApplicationDisplayName(f"{METADATA['name']}") self.qtApp.setApplicationDisplayName(f"{METADATA['name']}")
self.qtApp.setOrganizationName("Laborejo Software Suite") self.qtApp.setOrganizationName("Laborejo Software Suite")
self.qtApp.setOrganizationDomain("laborejo.org") self.qtApp.setOrganizationDomain("laborejo.org")
self.qtApp.setApplicationVersion(METADATA["version"]) self.qtApp.setApplicationVersion(METADATA["version"])
QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons. QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons.
logger.info("Init MainWindow") logger.info("Init MainWindow")
#QtGui.QIcon.setFallbackThemeName("hicolor") #only one, not a list. This is the fallback if the theme can't be found. Not if icons can't be found in a theme. #QtGui.QIcon.setFallbackThemeName("hicolor") #only one, not a list. This is the fallback if the theme can't be found. Not if icons can't be found in a theme.
#iconPaths = QtGui.QIcon.themeSearchPaths() #iconPaths = QtGui.QIcon.themeSearchPaths()
#iconPaths += ["/usr/share/icons/hicolor", "/usr/share/pixmaps"] #iconPaths += ["/usr/share/icons/hicolor", "/usr/share/pixmaps"]
#QtGui.QIcon.setThemeSearchPaths(iconPaths) #QtGui.QIcon.setThemeSearchPaths(iconPaths)
logger.info(f"Program icons path: {QtGui.QIcon.themeSearchPaths()}, {QtGui.QIcon.themeName()}") logger.info(f"Program icons path: {QtGui.QIcon.themeSearchPaths()}, {QtGui.QIcon.themeName()}")
#Set up the user interface from Designer and other widgets #Set up the user interface from Designer and other widgets
@ -150,15 +150,34 @@ class MainWindow(QtWidgets.QMainWindow):
#self.ui.stack_loaded_session is only visible when there is a loaded session and the full view tab is active #self.ui.stack_loaded_session is only visible when there is a loaded session and the full view tab is active
#we link the session context menu to the session menu menu. #we link the session context menu to the session menu menu.
self.ui.stack_loaded_session.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.ui.stack_loaded_session.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.ui.stack_loaded_session.customContextMenuRequested.connect(self.customContextMenu) self.ui.stack_loaded_session.customContextMenuRequested.connect(self.customContextMenu)
#Api Callbacks #Api Callbacks
api.callbacks.sessionClosed.append(self.reactCallback_sessionClosed) api.callbacks.sessionClosed.append(self.reactCallback_sessionClosed)
api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen) api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen)
api.callbacks.singleInstanceActivateWindow.append(self.activateAndRaise) api.callbacks.singleInstanceActivateWindow.append(self.activateAndRaise)
#Handle the application data cache.
#This must happen before engineStart. If a session is already running a set of initial
#client-callbacks will arrive immediately, even before the eventLoop starts.
#If not present instruct the engine to build one.
#This is also needed by the prompt in sessionController
logger.info("Trying to restore cached program database")
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("programDatabase"):
listOfDicts = settings.value("programDatabase", type=list)
api.setSystemsPrograms(listOfDicts)
logger.info("Restored program database from qt cache to engine")
self._updateGUIWithCachedPrograms()
else: #First or fresh start
#A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready.
logger.info("First run. Instructing engine to build program database")
QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms()
#Starting the engine sends initial GUI data. Every window and widget must be ready to receive callbacks here #Starting the engine sends initial GUI data. Every window and widget must be ready to receive callbacks here
api.eventLoop.start() api.eventLoop.start()
api.startEngine() api.startEngine()
@ -171,7 +190,7 @@ class MainWindow(QtWidgets.QMainWindow):
logger.info("Starting visible") logger.info("Starting visible")
self.toggleVisible(force=True) self.toggleVisible(force=True)
if PATHS["continueLastSession"]: #will be None if --sesion NAME was given as command line parameter and --continue on top. if PATHS["continueLastSession"]: #will be None if --load-session=NAME was given as command line parameter and --continue on top.
continueSession = self.recentlyOpenedSessions.last() continueSession = self.recentlyOpenedSessions.last()
if continueSession: if continueSession:
logger.info(f"Got continue session as command line parameter. Opening: {continueSession}") logger.info(f"Got continue session as command line parameter. Opening: {continueSession}")
@ -179,21 +198,8 @@ class MainWindow(QtWidgets.QMainWindow):
else: else:
logger.info(f"Got continue session as command line parameter but there is no session available.") logger.info(f"Got continue session as command line parameter but there is no session available.")
#Handle the application data cache. If not present instruct the engine to build one.
#This is also needed by the prompt in sessionController
logger.info("Trying to restore cached program database")
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("programDatabase"):
listOfDicts = settings.value("programDatabase", type=list)
api.setSystemsPrograms(listOfDicts)
logger.info("Restored program database from qt cache to engine")
self._updateGUIWithCachedPrograms()
else: #First or fresh start
#A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready.
logger.info("First run. Instructing engine to build program database")
QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms()
logger.info("Deciding if we run as tray-icon or window")
if not self.isVisible(): if not self.isVisible():
text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready") text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready")
self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal. self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal.
@ -243,7 +249,7 @@ class MainWindow(QtWidgets.QMainWindow):
def _updateIcons(self): def _updateIcons(self):
logger.info("Creating icon database") logger.info("Creating icon database")
programs = api.getSystemPrograms() programs = api.getSystemPrograms()
self.programIcons.clear() self.programIcons.clear()
for entry in programs: for entry in programs:
exe = entry["argodejoExec"] exe = entry["argodejoExec"]
@ -350,7 +356,7 @@ class MainWindow(QtWidgets.QMainWindow):
The TrayIcon provides another method of quitting that does not call this function, The TrayIcon provides another method of quitting that does not call this function,
but it will call _actualQuit. but it will call _actualQuit.
""" """
if api.currentSession(): if api.ourOwnServer() and api.currentSession():
result = self._askBeforeQuit(api.currentSession()) result = self._askBeforeQuit(api.currentSession())
else: else:
result = True result = True

27
qtgui/opensessioncontroller.py

@ -74,7 +74,7 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
self.setText(index, text) self.setText(index, text)
def updateData(self, clientDict:dict): def updateData(self, clientDict:dict):
"""Arrives via parenTreeWidget api callback""" """Arrives via parenTreeWidget api callback statusChanged, which is nsm status changed"""
self.clientDict = clientDict self.clientDict = clientDict
for index, key in enumerate(self.parentController.clientsTreeWidgetColumns): for index, key in enumerate(self.parentController.clientsTreeWidgetColumns):
if clientDict[key] is None: if clientDict[key] is None:
@ -83,9 +83,9 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
value = clientDict[key] value = clientDict[key]
if key == "visible": if key == "visible":
if value == True: if value == True:
t = QtCore.QCoreApplication.translate("OpenSession", "") t = ""
else: else:
t = QtCore.QCoreApplication.translate("OpenSession", "") t = ""
elif key == "dirty": elif key == "dirty":
if value == True: if value == True:
t = QtCore.QCoreApplication.translate("OpenSession", "not saved") t = QtCore.QCoreApplication.translate("OpenSession", "not saved")
@ -112,11 +112,6 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
if clientDict["reportedName"] is None: if clientDict["reportedName"] is None:
self.setText(nameColumn, clientDict["executable"]) self.setText(nameColumn, clientDict["executable"])
#TODO: this should be an nsmd status. Check if excecutable exists. nsmd just reports "stopped", and worse: after a long timeout.
if clientDict["lastStatus"] == "stopped" and clientDict["reportedName"] is None:
self.setText(self.parentController.clientsTreeWidgetColumns.index("lastStatus"), QtCore.QCoreApplication.translate("OpenSession", "(command not found)"))
class ClientTable(object): class ClientTable(object):
"""Controls the QTreeWidget that holds loaded clients""" """Controls the QTreeWidget that holds loaded clients"""
@ -129,8 +124,8 @@ class ClientTable(object):
self.sortByColumn = 0 #by name self.sortByColumn = 0 #by name
self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
self.clientsTreeWidget = self.mainWindow.ui.loadedSessionClients self.clientsTreeWidget = self.mainWindow.ui.loadedSessionClients
self.clientsTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.clientsTreeWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.clientsTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu) self.clientsTreeWidget.customContextMenuRequested.connect(self.clientsContextMenu)
self.clientsTreeWidget.setIconSize(iconSize) self.clientsTreeWidget.setIconSize(iconSize)
@ -182,17 +177,17 @@ class ClientTable(object):
"""Reuses the menubar menus""" """Reuses the menubar menus"""
pos = QtGui.QCursor.pos() pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5) pos.setY(pos.y() + 5)
item = self.clientsTreeWidget.itemAt(qpoint) item = self.clientsTreeWidget.itemAt(qpoint)
if not type(item) is ClientItem: if not type(item) is ClientItem:
self.mainWindow.ui.menuSession.exec_(pos) self.mainWindow.ui.menuSession.exec_(pos)
return return
if not item is self.clientsTreeWidget.currentItem(): if not item is self.clientsTreeWidget.currentItem():
#Some mouse combinations can lead to getting a different context menu than the clicked item. #Some mouse combinations can lead to getting a different context menu than the clicked item.
self.clientsTreeWidget.setCurrentItem(item) self.clientsTreeWidget.setCurrentItem(item)
menu = self.mainWindow.ui.menuClientNameId menu = self.mainWindow.ui.menuClientNameId
menu.exec_(pos) menu.exec_(pos)
@ -235,7 +230,7 @@ class ClientTable(object):
def _reactCallback_clientStatusChanged(self, clientDict:dict): def _reactCallback_clientStatusChanged(self, clientDict:dict):
"""The major client callback. Maps to nsmd status changes. """The major client callback. Maps to nsmd status changes.
We will create and delete client tableWidgetItems based on this We will create and delete client tableWidgetItems based on this
""" """
assert clientDict assert clientDict
clientId = clientDict["clientId"] clientId = clientDict["clientId"]
if clientId in ClientItem.allItems: if clientId in ClientItem.allItems:
@ -480,5 +475,5 @@ class OpenSessionController(object):
def _reactCallback_sessionOpen(self, nsmSessionExportDict:dict): def _reactCallback_sessionOpen(self, nsmSessionExportDict:dict):
"""Open does not mean we come from the session chooser. Switching does not close a session""" """Open does not mean we come from the session chooser. Switching does not close a session"""
#self.description.clear() #Deletes the placesholder and text! #self.description.clear() #Deletes the placesholder and text!
self.sessionLoadedPanel.setTitle(nsmSessionExportDict["nsmSessionName"]) self.sessionLoadedPanel.setTitle(nsmSessionExportDict["nsmSessionName"])

39
qtgui/quickopensessioncontroller.py

@ -84,7 +84,7 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
icon = programIcons[desktopEntry["argodejoExec"]] icon = programIcons[desktopEntry["argodejoExec"]]
self.setIcon(icon) self.setIcon(icon)
self.updateStatus(None) #removed/off self.updateStatus(None) #removed/off
def updateStatus(self, clientDict:dict): def updateStatus(self, clientDict:dict):
""" """
@ -108,40 +108,40 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
self.removed() self.removed()
else: else:
getattr(self, clientDict["lastStatus"], nothing)() getattr(self, clientDict["lastStatus"], nothing)()
def _setIconOverlay(self, status:str): def _setIconOverlay(self, status:str):
options = { options = {
"removed": ":alert.svg", "removed": ":alert.svg",
"stopped": ":power.svg", "stopped": ":power.svg",
"hidden": ":hidden.svg", "hidden": ":hidden.svg",
"ready": ":running.svg", "ready": ":running.svg",
} }
if status in options: if status in options:
overlayPixmap = QtGui.QIcon(options[status]).pixmap(QtCore.QSize(30,30)) overlayPixmap = QtGui.QIcon(options[status]).pixmap(QtCore.QSize(30,30))
shadow = QtGui.QIcon(options[status]).pixmap(QtCore.QSize(35,35)) #original color is black shadow = QtGui.QIcon(options[status]).pixmap(QtCore.QSize(35,35)) #original color is black
#Colorize overlay symbol. Painter works inplace. #Colorize overlay symbol. Painter works inplace.
painter = QtGui.QPainter(overlayPixmap); painter = QtGui.QPainter(overlayPixmap);
painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceIn) painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceIn)
painter.fillRect(overlayPixmap.rect(), QtGui.QColor("cyan")) painter.fillRect(overlayPixmap.rect(), QtGui.QColor("cyan"))
painter.end() painter.end()
icon = self.parentController.mainWindow.programIcons[self.argodejoExec] icon = self.parentController.mainWindow.programIcons[self.argodejoExec]
pixmap = icon.pixmap(QtCore.QSize(70,70)) pixmap = icon.pixmap(QtCore.QSize(70,70))
p = QtGui.QPainter(pixmap) p = QtGui.QPainter(pixmap)
p.drawPixmap(0, -1, shadow) p.drawPixmap(0, -1, shadow)
p.drawPixmap(2, 2, overlayPixmap) #top left corner of icon, with some padding for the shadow p.drawPixmap(2, 2, overlayPixmap) #top left corner of icon, with some padding for the shadow
p.end() p.end()
ico = QtGui.QIcon(pixmap) ico = QtGui.QIcon(pixmap)
self.setIcon(ico) self.setIcon(ico)
else: else:
if self.argodejoExec in self.parentController.mainWindow.programIcons: #there was a strange bug once where this happened exactly one, and then everything was fine, including this icon. Some DB backwards compatibility. if self.argodejoExec in self.parentController.mainWindow.programIcons: #there was a strange bug once where this happened exactly one, and then everything was fine, including this icon. Some DB backwards compatibility.
ico = self.parentController.mainWindow.programIcons[self.argodejoExec] ico = self.parentController.mainWindow.programIcons[self.argodejoExec]
self.setIcon(ico) self.setIcon(ico)
#Status #Status
def ready(self): def ready(self):
@ -152,8 +152,8 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
self._setIconOverlay("hidden") self._setIconOverlay("hidden")
else: else:
self._setIconOverlay("ready") self._setIconOverlay("ready")
#self.setFlags(QtCore.Qt.ItemIsEnabled) #self.setFlags(QtCore.Qt.ItemIsEnabled)
def removed(self): def removed(self):
#self.setFlags(QtCore.Qt.NoItemFlags) #Black and white. We can still mouseClick through parent signal when set to NoItemFlags #self.setFlags(QtCore.Qt.NoItemFlags) #Black and white. We can still mouseClick through parent signal when set to NoItemFlags
@ -163,11 +163,11 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
self.setFlags(QtCore.Qt.ItemIsEnabled) self.setFlags(QtCore.Qt.ItemIsEnabled)
self._setIconOverlay("stopped") self._setIconOverlay("stopped")
def handleClick(self): def handleClick(self):
alreadyInSession = api.executableInSession(self.argodejoExec) alreadyInSession = api.executableInSession(self.argodejoExec)
#Paranoia Start #Paranoia Start
if self.nsmClientDict is None and alreadyInSession: if self.nsmClientDict is None and alreadyInSession:
#Caught double-click. do nothing, this is a user-accident #Caught double-click. do nothing, this is a user-accident
return return
elif self.nsmClientDict: elif self.nsmClientDict:
assert alreadyInSession assert alreadyInSession
@ -251,6 +251,7 @@ class QuickOpenSessionController(object):
def _openReady(self, nsmSessionExportDict): def _openReady(self, nsmSessionExportDict):
self._nsmSessionExportDict = nsmSessionExportDict self._nsmSessionExportDict = nsmSessionExportDict
self.nameWidget.setText(nsmSessionExportDict["nsmSessionName"])
def _sendNameChange(self): def _sendNameChange(self):
"""The closed callback is send on start to indicate "no open session". exportDict cache is """The closed callback is send on start to indicate "no open session". exportDict cache is
@ -320,8 +321,10 @@ class QuickOpenSessionController(object):
We also present only one icon per executable. If you want more go into the other mode. We also present only one icon per executable. If you want more go into the other mode.
""" """
#index = self.listWidget.indexFromItem(QuickClientItem.allItems[clientId]).row() #Row is the real index in a listView, no matter iconViewMode. #index = self.listWidget.indexFromItem(QuickClientItem.allItems[clientId]).row() #Row is the real index in a listView, no matter iconViewMode.
if clientDict["dumbClient"]:
#This includes the initial loading of nsm-clients. assert clientDict["executable"]
if clientDict["dumbClient"]: #only real nsm clients in our session, whic includes the initial "not-yet" status of nsm-clients.
return return
backgroundClients = METADATA["preferredClients"].values() backgroundClients = METADATA["preferredClients"].values()
if clientDict["executable"] in backgroundClients: if clientDict["executable"] in backgroundClients:

2
qtgui/sessiontreecontroller.py

@ -188,6 +188,8 @@ class SessionTreeController(object):
We also get this for every client change so we can update our numbers""" We also get this for every client change so we can update our numbers"""
self.treeWidget.clear() self.treeWidget.clear()
print ("Callback sessions changed. list: ")
self._cachedSessionDicts = sessionDicts #in case we change the flat/nested mode. self._cachedSessionDicts = sessionDicts #in case we change the flat/nested mode.
for sessionDict in sessionDicts: for sessionDict in sessionDicts:

Loading…
Cancel
Save