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)
def startEngine():
def startEngine():
logger.info("Start Engine")
global eventLoop
@ -153,6 +153,7 @@ def startEngine():
dataClientDescriptionHook=callbacks._dataClientDescriptionChanged,
parameterNsmOSCUrl=PATHS["url"],
sessionRoot=PATHS["sessionRoot"],
startupSession=PATHS["startupSession"],
)
#Watch session tree for changes.
@ -172,19 +173,31 @@ def startEngine():
#Send initial data
#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.
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.
#Until we return from startEngine a GUI will also not create its mainwindow. This can be used to create the appearance
#of a session-on-load:
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
#Until we return from startEngine a GUI will also not create its mainwindow.
logger.info("Engine start complete")
#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():
return nsmServerControl.sessionRoot

217
engine/nsmservercontrol.py

@ -40,6 +40,9 @@ from uuid import uuid4
from datetime import datetime
from sys import exit as sysexit
def nothing(*args, **kwargs):
pass
class _IncomingMessage(object):
"""Representation of a parsed datagram representing an OSC message.
@ -295,7 +298,8 @@ class NsmServerControl(object):
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.
This is just a development mode to see all messages, unfiltered.
@ -303,6 +307,10 @@ class NsmServerControl(object):
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.
#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._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
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.
if useCallbacks:
self.callbacks = {
@ -387,7 +386,7 @@ class NsmServerControl(object):
#No further action required. GUI announce below this testing.
pass
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))
#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.internalState["sessionRoot"] = self.sessionRoot
self._forceProcessOnceToEmptyQueue() #process any leftover messages.
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
logger.info("nsmservercontrol init is complete. Ready for event loop")
#Now an external event loop can add self.process
#Internal Methods
@ -448,7 +460,7 @@ class NsmServerControl(object):
"""Set both the socket and the thread into waiting mode or not.
With this we can wait for answers until we resume async operation"""
if state:
self.sock.setblocking(True) #explicitly wait.
self.sock.setblocking(True) #explicitly wait.
self.sock.settimeout(0.5)
self._receiverActive = False
logger.info("Suspending receiving async mode.")
@ -457,6 +469,41 @@ class NsmServerControl(object):
self._receiverActive = True
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):
"""Use this in an external event loop"""
if self._receiverActive:
@ -513,29 +560,36 @@ class NsmServerControl(object):
finally:
tempServerSock.close()
def _startNsmdOurselves(self, sessionRoot:str):
def _startNsmdOurselves(self, sessionRoot:str, startupSession:str):
assert self.nsmOSCUrl
hostname, port = self.nsmOSCUrl
hostname, port = self.nsmOSCUrl
arguments = ["nsmd","--osc-port", str(port)]
if sessionRoot:
self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port), "--session-root", sessionRoot])
else:
self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port)])
arguments += ["--session-root", sessionRoot]
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:
"""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
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.
Returns list of arguments, can be empty.
"""
assert not self._queue, [(m.oscpath, m.params) for m in self._queue]
logger.info(f"[wait for answer]: Sending {path}: {arguments}")
self._setPause(True)
self._setPause(True)
out_msg = _OutgoingMessage(path)
for arg in arguments:
out_msg.add_arg(arg)
@ -547,19 +601,19 @@ class NsmServerControl(object):
ready = False
while not ready:
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:
data, addr = self.sock.recvfrom(1024)
msg = _IncomingMessage(data)
if answerArguments and msg.oscpath == answerPath and msg.params == answerArguments:
result = msg.params
logger.info(f"[wait from {path}] Received {answerPath}: {result}")
ready = True
ready = True
elif msg.oscpath == answerPath:
result = msg.params
logger.info(f"[wait from {path}] Received {answerPath}: {result}")
ready = True
else:
else:
logger.warning(f"Waiting for {answerPath} from nsmd, but got: {msg.oscpath} with {msg.params}. Adding to queue for later.")
self._queue.append(msg)
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",
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=[])
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")
self._queue.clear()
self._queue.clear() #this is safe because we tested above that there is exactly the hi message in the queue.
else:
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
return pathlib.Path(resultArguments[0])
@ -604,7 +662,7 @@ class NsmServerControl(object):
def gui_announce(self):
"""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")
self.sock.sendto(msg.build(), self.nsmOSCUrl)
@ -621,12 +679,19 @@ class NsmServerControl(object):
like copy, rename and delete.
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.
First is /nsm/gui/server/message ['Listing sessions']
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")
self._setPause(True)
@ -650,7 +715,7 @@ class NsmServerControl(object):
self._queue.append(msg)
continue
#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', ''] as "list ended" marker
if msg.params[1]:
@ -694,12 +759,12 @@ class NsmServerControl(object):
self.sock.sendto(message.build(), self.nsmOSCUrl)
#Primarily Without Session
def open(self, nsmSessionName:str):
def open(self, nsmSessionName:str):
if nsmSessionName in self.internalState["sessions"]:
msg = _OutgoingMessage("/nsm/server/open")
msg.add_arg(nsmSessionName) #s:project_name
self.sock.sendto(msg.build(), self.nsmOSCUrl)
else:
else:
logger.warning(f"Session {nsmSessionName} not found. Not forwarding to nsmd.")
def new(self, newName:str, startClients:list=[])->str:
@ -708,7 +773,7 @@ class NsmServerControl(object):
"""
basePath = pathlib.Path(self.sessionRoot, newName)
if basePath.exists():
return None
return None
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
path.
"""
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")
return False
@ -790,7 +856,11 @@ class NsmServerControl(object):
def clientRemove(self, clientId:str):
"""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.
if not clientId in self.internalState["clients"]:
return False
@ -914,6 +984,11 @@ class NsmServerControl(object):
parameters[0] is an osc path:str without naming constraints
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}")
self.internalState["broadcasts"].append(parameters)
@ -935,19 +1010,19 @@ class NsmServerControl(object):
errorCode, message = parameters
assert errorCode == 0, errorCode
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:
raise NotImplementedError(parameters)
def _reactCallback_activeSessionChanged(self, parameters:list):
"""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.
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
Shortly before we received /nsm/gui/session/session which indicates the attempt to create a
new one, I guess! :)
@ -962,7 +1037,7 @@ class NsmServerControl(object):
nsmSessionName, sessionPath = parameters
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.")
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()
else:
sessionPath = sessionPath.lstrip("/")
@ -972,24 +1047,32 @@ class NsmServerControl(object):
#We have a counterpart-message reaction that signals the attempt to load.
self.sessionOpenReadyHook(self.sessionAsDict(nsmSessionName)) #notify the api->UI
for autoClientExecutableInPath in self._addToNextSession:
self.clientAdd(autoClientExecutableInPath)
self._addToNextSession = [] #reset
self.clientAdd(autoClientExecutableInPath)
self._addToNextSession = [] #reset
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()
else:
raise NotImplementedError(parameters)
def _initializeEmptyClient(self, clientId:str):
"""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"]:
return
logger.info(f"Creating new internal entry for client {clientId}")
client = {
"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.
"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.
"label":None, #str
"lastStatus":None, #str
@ -1010,7 +1093,7 @@ class NsmServerControl(object):
def _reactCallback_ClientLabelChanged(self, parameters:list):
"""osc->add_method( "/nsm/gui/client/label", "ss", osc_handler, osc, "path,display_name" );
"""
"""
l = len(parameters)
if l == 2:
clientId, label = parameters
@ -1035,25 +1118,25 @@ class NsmServerControl(object):
def _reactCallback_ClientNew(self, parameters:list):
"""/nsm/gui/client/new ['nBAVO', 'jackpatch']
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
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
of the session.
A loaded session will directly start with the reported/announced name.
of the session. Executable name is still important to look up icons in the GUI.
This message is usually followed by /nsm/gui/client/status
"""
l = len(parameters)
if l == 2:
clientId, executableName = parameters
clientId, name = parameters
if not clientId in self.internalState["clients"]:
self._initializeEmptyClient(clientId)
self._setClientData(clientId, "executable", executableName)
logger.info(f"Client started {executableName}:{clientId}")
self._initializeEmptyClient(clientId)
self._setClientData(clientId, "executable", name)
logger.info(f"Client started {name}:{clientId}")
else:
self._setClientData(clientId, "reportedName", executableName)
logger.info(f"Client upgraded to NSM-compatible: {executableName}:{clientId}")
self._setClientData(clientId, "reportedName", name)
logger.info(f"Client upgraded to NSM-compatible: {name}:{clientId}")
self.clientStatusHook(self.internalState["clients"][clientId])
else:
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.
self._setClientData(clientId, "dumbClient", False)
self.clientStatusHook(self.internalState["clients"][clientId])
else:
raise NotImplementedError(parameters)
@ -1186,7 +1269,7 @@ class NsmServerControl(object):
after start by the GUI.
"""
pass
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.")
return False
else:
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.
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.
oldPath.rename(tmp)
pathlib.Path(newPath).mkdir(parents=True, exist_ok=True)
tmp.rename(newPath)
tmp.rename(newPath)
assert newPath.exists()
def copySession(self, nsmSessionName:str, newName:str):
@ -1421,7 +1504,6 @@ class NsmServerControl(object):
if not sessionFile.exists():
#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))
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["lockFile"] = pathlib.Path(basePath, ".lock")
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["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
@ -1440,7 +1522,10 @@ class NsmServerControl(object):
def exportSessionsAsDicts(self)->list:
"""Return a list of dicts of projects with additional information:
"""
logger.info("Exporting sessions to dict. Will call blocking list sessions next")
results = []
#assert not self.internalState["currentSession"], self.internalState["currentSession"] #Do not request session list while in active session
self._updateSessionListBlocking()
for nsmSessionName in self.internalState["sessions"]:
result = self.sessionAsDict(nsmSessionName)
@ -1484,7 +1569,7 @@ class DataStorage(object):
def descriptionToParentAndCallbacks(self):
"""Every char!!!"""
self.parent.dataClientDescriptionHook(self.data["description"])
self.parent.dataClientDescriptionHook(self.data["description"])
def _waitForMultipartMessage(self, pOscpath:str)->str:
"""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)
except socket.timeout:
break
msg = _IncomingMessage(data)
if msg.oscpath == pOscpath:
currentPartNumber, l, jsonChunk = msg.params
@ -1546,7 +1631,7 @@ class DataStorage(object):
except socket.timeout:
break
msg = _IncomingMessage(data)
msg = _IncomingMessage(data)
if msg.oscpath == "/argodejo/datastorage/reply/getclient":
replyClientId, jsonName = msg.params
assert replyClientId == clientId, (replyClientId, clientId)
@ -1558,7 +1643,7 @@ class DataStorage(object):
answer = json.loads(jsonName)
if 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.
if clientId in self.data["clientOverrideNames"]:
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("--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("-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'")
@ -88,11 +88,11 @@ try:
compiledVersion = True
logger.info("Compiled prefix found: {}".format(prefix))
except ModuleNotFoundError as e:
compiledVersion = False
compiledVersion = False
logger.info("Compiled version: {}".format(compiledVersion))
if compiledVersion:
if compiledVersion:
PATHS={ #this gets imported
"root": "",
"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
"share": os.path.join(prefix, "share", METADATA["shortName"]),
"templateShare": os.path.join(prefix, "share", METADATA["shortName"], "template"),
"sessionRoot": args.sessionRoot,
"sessionRoot": args.sessionRoot,
"url": args.url,
"startupSession": args.session,
"startHidden": args.starthidden, #bool
"continueLastSession": args.continueLastSession, #bool
}
}
_root = os.path.dirname(__file__)
_root = os.path.abspath(os.path.join(_root, ".."))
else:
_root = os.path.dirname(__file__)
_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
"share": os.path.join(_root, "engine", "resources"),
"templateShare": os.path.join(_root, "template", "engine", "resources"),
"sessionRoot": args.sessionRoot,
"sessionRoot": args.sessionRoot,
"url": args.url,
"startupSession": args.session,
"startHidden": args.starthidden, #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))
@ -144,8 +148,8 @@ def exitWithMessage(message:str):
sys.exit(title + ": " + message)
else:
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.
QMessageBox.critical(qtApp.desktop(), title, message)
#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)
sys.exit(title + ": " + message)
def setProcessName(executableName):
@ -168,7 +172,7 @@ def setProcessName(executableName):
libpthread_path = ctypes.util.find_library("pthread")
if not libpthread_path:
return
libpthread = ctypes.CDLL(libpthread_path)
if hasattr(libpthread, "pthread_setname_np"):
_pthread_setname_np = libpthread.pthread_setname_np
@ -182,9 +186,9 @@ def setProcessName(executableName):
if _pthread_setname_np is None:
return
_pthread_setname_np(_pthread_self(), executableName.encode())
def _is_jack_running():
"""Check for JACK"""
@ -227,12 +231,12 @@ except:
#Catch Exceptions even if PyQt crashes.
import sys
sys._excepthook = sys.excepthook
sys._excepthook = sys.excepthook
def exception_hook(exctype, value, traceback):
"""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)
logger.error("Caught crash in execpthook. Trying too execute atexit anyway")
sys._excepthook(exctype, value, traceback)
sys.exit(1)
sys._excepthook(exctype, value, traceback)
sys.exit(1)
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.setApplicationName(f"{METADATA['name']}")
self.qtApp.setApplicationDisplayName(f"{METADATA['name']}")
self.qtApp.setOrganizationName("Laborejo Software Suite")
self.qtApp.setOrganizationDomain("laborejo.org")
self.qtApp.setApplicationVersion(METADATA["version"])
QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons.
self.qtApp.setOrganizationName("Laborejo Software Suite")
self.qtApp.setOrganizationDomain("laborejo.org")
self.qtApp.setApplicationVersion(METADATA["version"])
QtGui.QIcon.setThemeName("hicolor") #audio applications can be found here. We have no need for other icons.
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.
#iconPaths = QtGui.QIcon.themeSearchPaths()
#iconPaths += ["/usr/share/icons/hicolor", "/usr/share/pixmaps"]
#QtGui.QIcon.setThemeSearchPaths(iconPaths)
logger.info(f"Program icons path: {QtGui.QIcon.themeSearchPaths()}, {QtGui.QIcon.themeName()}")
#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
#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)
#Api Callbacks
api.callbacks.sessionClosed.append(self.reactCallback_sessionClosed)
api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen)
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
api.eventLoop.start()
api.startEngine()
@ -171,7 +190,7 @@ class MainWindow(QtWidgets.QMainWindow):
logger.info("Starting visible")
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()
if continueSession:
logger.info(f"Got continue session as command line parameter. Opening: {continueSession}")
@ -179,21 +198,8 @@ class MainWindow(QtWidgets.QMainWindow):
else:
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():
text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready")
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):
logger.info("Creating icon database")
programs = api.getSystemPrograms()
self.programIcons.clear()
for entry in programs:
exe = entry["argodejoExec"]
@ -350,7 +356,7 @@ class MainWindow(QtWidgets.QMainWindow):
The TrayIcon provides another method of quitting that does not call this function,
but it will call _actualQuit.
"""
if api.currentSession():
if api.ourOwnServer() and api.currentSession():
result = self._askBeforeQuit(api.currentSession())
else:
result = True

27
qtgui/opensessioncontroller.py

@ -74,7 +74,7 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
self.setText(index, text)
def updateData(self, clientDict:dict):
"""Arrives via parenTreeWidget api callback"""
"""Arrives via parenTreeWidget api callback statusChanged, which is nsm status changed"""
self.clientDict = clientDict
for index, key in enumerate(self.parentController.clientsTreeWidgetColumns):
if clientDict[key] is None:
@ -83,9 +83,9 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
value = clientDict[key]
if key == "visible":
if value == True:
t = QtCore.QCoreApplication.translate("OpenSession", "")
t = ""
else:
t = QtCore.QCoreApplication.translate("OpenSession", "")
t = ""
elif key == "dirty":
if value == True:
t = QtCore.QCoreApplication.translate("OpenSession", "not saved")
@ -112,11 +112,6 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
if clientDict["reportedName"] is None:
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):
"""Controls the QTreeWidget that holds loaded clients"""
@ -129,8 +124,8 @@ class ClientTable(object):
self.sortByColumn = 0 #by name
self.sortAscending = 0 # Qt::SortOrder which is 0 for ascending and 1 for descending
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.setIconSize(iconSize)
@ -182,17 +177,17 @@ class ClientTable(object):
"""Reuses the menubar menus"""
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
item = self.clientsTreeWidget.itemAt(qpoint)
if not type(item) is ClientItem:
if not type(item) is ClientItem:
self.mainWindow.ui.menuSession.exec_(pos)
return
if not item is self.clientsTreeWidget.currentItem():
#Some mouse combinations can lead to getting a different context menu than the clicked item.
self.clientsTreeWidget.setCurrentItem(item)
menu = self.mainWindow.ui.menuClientNameId
menu = self.mainWindow.ui.menuClientNameId
menu.exec_(pos)
@ -235,7 +230,7 @@ class ClientTable(object):
def _reactCallback_clientStatusChanged(self, clientDict:dict):
"""The major client callback. Maps to nsmd status changes.
We will create and delete client tableWidgetItems based on this
"""
"""
assert clientDict
clientId = clientDict["clientId"]
if clientId in ClientItem.allItems:
@ -480,5 +475,5 @@ class OpenSessionController(object):
def _reactCallback_sessionOpen(self, nsmSessionExportDict:dict):
"""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"])

39
qtgui/quickopensessioncontroller.py

@ -84,7 +84,7 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
icon = programIcons[desktopEntry["argodejoExec"]]
self.setIcon(icon)
self.updateStatus(None) #removed/off
def updateStatus(self, clientDict:dict):
"""
@ -108,40 +108,40 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
self.removed()
else:
getattr(self, clientDict["lastStatus"], nothing)()
def _setIconOverlay(self, status:str):
options = {
"removed": ":alert.svg",
"stopped": ":power.svg",
"stopped": ":power.svg",
"hidden": ":hidden.svg",
"ready": ":running.svg",
"ready": ":running.svg",
}
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
#Colorize overlay symbol. Painter works inplace.
painter = QtGui.QPainter(overlayPixmap);
painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceIn)
painter.fillRect(overlayPixmap.rect(), QtGui.QColor("cyan"))
painter.end()
painter.end()
icon = self.parentController.mainWindow.programIcons[self.argodejoExec]
pixmap = icon.pixmap(QtCore.QSize(70,70))
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.end()
ico = QtGui.QIcon(pixmap)
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.
ico = self.parentController.mainWindow.programIcons[self.argodejoExec]
self.setIcon(ico)
#Status
def ready(self):
@ -152,8 +152,8 @@ class StarterClientItem(QtWidgets.QListWidgetItem):
self._setIconOverlay("hidden")
else:
self._setIconOverlay("ready")
#self.setFlags(QtCore.Qt.ItemIsEnabled)
#self.setFlags(QtCore.Qt.ItemIsEnabled)
def removed(self):
#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._setIconOverlay("stopped")
def handleClick(self):
def handleClick(self):
alreadyInSession = api.executableInSession(self.argodejoExec)
#Paranoia Start
#Paranoia Start
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
elif self.nsmClientDict:
assert alreadyInSession
@ -251,6 +251,7 @@ class QuickOpenSessionController(object):
def _openReady(self, nsmSessionExportDict):
self._nsmSessionExportDict = nsmSessionExportDict
self.nameWidget.setText(nsmSessionExportDict["nsmSessionName"])
def _sendNameChange(self):
"""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.
"""
#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
backgroundClients = METADATA["preferredClients"].values()
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"""
self.treeWidget.clear()
print ("Callback sessions changed. list: ")
self._cachedSessionDicts = sessionDicts #in case we change the flat/nested mode.
for sessionDict in sessionDicts:

Loading…
Cancel
Save