From cbf2b17e498d270fe084c2cca452f4c5232846be Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:14:17 -0400 Subject: [PATCH 01/17] Move zeroconf tool into Resources/zeroconf.py - This will make it easier to write unit tests later. - This is consistent with the example set in Utilities/Calculator.app. - ./Zeroconf is now just a shell script pointing to the Python application. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 310 ++++++++++++++++++ Utilities/Zeroconf.app/Zeroconf | 313 +------------------ 2 files changed, 313 insertions(+), 310 deletions(-) create mode 100755 Utilities/Zeroconf.app/Resources/zeroconf.py diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py new file mode 100755 index 00000000..ac4bc5d6 --- /dev/null +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3.7 + + +# Simple Zeroconf browser written for FreeBSD in PyQt5 + + +# Copyright (c) 2020, Simon Peter +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +import os, sys, time + +try: + from PyQt5 import QtWidgets, QtGui, QtCore +except: + print("Could not import PyQt5. On FreeBSD, sudo pkg install py37-qt5-widgets") + +# https://stackoverflow.com/a/377028 +def which(program): + + def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + fpath, fname = os.path.split(program) + if fpath: + if is_exe(program): + return program + else: + for path in os.environ["PATH"].split(os.pathsep): + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return exe_file + + return None + + +class ZeroconfService(): + + def __init__(self, avahi_browse_line, browser): + self.browser = browser + parts = avahi_browse_line.split(";") + if len(parts) < 10: + return + self.interface = parts[1] + self.ip_version = parts[2] + self.name = parts[3] # .decode("utf-8", "strict") + self.service_type = parts[4] + self.domain = parts[5] + self.hostname_with_domain = parts[6] + self.address = parts[7] + self.port = parts[8] + self.txt = parts[9] + self.url = "%s://%s:%s" % (self.service_type.split("_")[1].split("-")[0].replace(".", ""), self.hostname_with_domain, self.port) + + def __repr__(self): + return "%s on %s:%s" % (self.service_type, self.hostname_with_domain, self.port) + + # Define here what we should do with detected services. This gets run whenever a service is added + def handle(self): + print("Handling %s", str(self)) + icon = "unknown" + if self.url.startswith("device"): + icon = "computer" + if self.url.startswith("ssh"): + icon = "terminal" + if self.url.startswith("sftp") or self.url.startswith("smb"): + icon = "folder" + if self.url.startswith("raop"): + # AirPlay + icon = "network-wireless" + if self.url.startswith("pulse"): + # PulseAudio + icon = "audio-card" + if self.url.startswith("scanner") or self.url.startswith("uscan"): + icon = "scanner" + if self.url.startswith("http"): + icon = "applications-internet" + if self.url.startswith("ipp") or self.url.startswith("print") or self.url.startswith("pdl"): + icon = "printer" + item = QtWidgets.QListWidgetItem(QtGui.QIcon.fromTheme(icon), self.url) + self.browser.list_widget.addItem(item) + + +class ZeroconfServices(): + + def __init__(self): + self.services = [] + + def add(self, service): + if service.ip_version == "IPv4": + print("Appending " + str(service)) + self.services.append(service) + service.handle() + else: + print("service.ip_version: %s" % service.ip_version) + print("Not appending since IPv6; TODO: Show those as well but check for duplicates") + + def remove(self, avahi_browse_line): + print("TODO: To be implemented: Remove the service from the list if certain criteria match") + for service in self.services: + print(service.service_type) + print(service.hostname_with_domain) + +class sshLogin(QtWidgets.QDialog): + def __init__(self, host=None, parent=None): + super(sshLogin, self).__init__(parent) + self.host = host + self.setWindowTitle("Username") + self.textName = QtWidgets.QLineEdit(self) + self.buttonLogin = QtWidgets.QPushButton('Connect', self) + self.buttonLogin.clicked.connect(self.handleLogin) + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.textName) + layout.addWidget(self.buttonLogin) + + def handleLogin(self): + self.accept() # Close dialog + # Launch ssh in QTerminal + proc = QtCore.QProcess() + args = ["QTerminal", "-e", "ssh", self.host, "-l", self.textName.text()] + try: + proc.startDetached("launch", args) + except: + print("Cannot launch browser") + return + +class ZeroconfBrowser(object): + + def __init__(self): + + self.app = QtWidgets.QApplication(sys.argv) + + # Window + self.window = QtWidgets.QMainWindow() + self.window.setWindowTitle('Connnect to Server') + self.window.setMinimumWidth(500) + self.window.setMinimumHeight(350) + self.window.closeEvent = self.quit + self.layout = QtWidgets.QVBoxLayout() + + # List + self.list_widget = QtWidgets.QListWidget() + self.list_widget.setAlternatingRowColors(True) + self.list_widget.itemDoubleClicked.connect(self.onDoubleClicked) + self.layout.addWidget(self.list_widget) + + # Label + # self.label = QtWidgets.QLabel() + # self.label.setText("This application is written in PyQt5. It is very easy to extend. Look inside the .app to see its source code.") + # self.label.setWordWrap(True) + # self.layout.addWidget(self.label) + + self._showMenu() + + widget = QtWidgets.QWidget() + widget.setLayout(self.layout) + self.window.setCentralWidget(widget) + self.window.show() + + self.services = ZeroconfServices() + self.ext_process = QtCore.QProcess() + self.long_running_function() + + sys.exit(self.app.exec_()) + + def quit(self, event): + sys.exit(0) + + def onDoubleClicked(self): + print("Double clicked") + row = self.list_widget.selectedIndexes()[0].row() + print(self.services.services[row].url) + + if self.services.services[row].url.startswith("http"): + + # Find out the browser + # TODO: Give preference to the default browser the user may have set in the system. + # user@FreeBSD$ xdg-settings get default-web-browser + # userapp-Firefox-59CXP0.desktop + # Then no one knows where that file actually is, nor what it Exec= line contains. xdg is so convoluted! + # user@FreeBSD$ LANG=C x-www-browser + # bash: x-www-browser: command not found + # + # browser_candidates = [os.environ.get("BROWSER"), "x-www-browser", "chrome", "chromium-browser", "google-chrome", "firefox", "iceweasel", "seamonkey", "mozilla", "epiphany", "konqueror"] + # for browser_candidate in browser_candidates: + # if browser_candidate != None: + # if which(browser_candidate) != None: + # browser = browser_candidate + # print("Using as browser: %s" % browser) + # break + + # Launch the browser + proc = QtCore.QProcess() + args = [self.services.services[row].url] + try: + proc.startDetached("xdg-open", args) + return + except: + print("Cannot launch browser") + return + elif self.services.services[row].url.startswith("scanner") or self.services.services[row].url.startswith("uscan"): + # Launch Xsane in the hope that it can do something with it + os.system("xsane") + if self.services.services[row].url.startswith("ipp") or self.services.services[row].url.startswith("print") or self.services.services[row].url.startswith("pdl"): + os.system("launch 'Print Settings'") + elif self.services.services[row].url.startswith("ssh"): + # Launch the browser + sshL = sshLogin(host=self.services.services[row].url) + if sshL.exec_() == QtWidgets.QDialog.Accepted: + print("Do something") + else: + reply = QtWidgets.QMessageBox.information( + self.window, + "To be implemented", + "Something needs to be done here with\n%s\nPull requests welcome!" % self.services.services[row].url, + QtWidgets.QMessageBox.Yes + ) + + def long_running_function(self): + self.ext_process.finished.connect(self.onProcessFinished) + self.ext_process.setProgram("avahi-browse") + self.ext_process.setArguments(["-arlp"]) + + try: + pid = self.ext_process.start() + print("avahi-browse started") + except: + self.showErrorPage("avahi-browse cannot be launched.") + return # Stop doing anything here + + + if self.ext_process.waitForStarted(-1): + while True: + QtWidgets.QApplication.processEvents() # Important trick so that the app stays responsive without the need for threading! + time.sleep(0.1) + while self.ext_process.canReadLine(): + # This is a really crude attempt to read line-wise. FIXME: Do better + line = str(self.ext_process.readLine()) + self.processLine(line) + + print("ERROR: We should never reach this!") + + def onProcessFinished(self): + print("onProcessFinished called") + + def processLine(self, line): + line = str(line).replace("b'", "").replace("\\n'", "") + print(line) + if line.startswith("="): + s = ZeroconfService(line, self) + self.services.add(s) + if line.startswith("-"): + s = ZeroconfService(line, self) + self.services.remove(line) + + def _showMenu(self): + exitAct = QtWidgets.QAction('&Quit', self.window) + exitAct.setShortcut('Ctrl+Q') + exitAct.setStatusTip('Exit application') + exitAct.triggered.connect(sys.exit, 0) + menubar = self.window.menuBar() + fileMenu = menubar.addMenu('&File') + fileMenu.addAction(exitAct) + aboutAct = QtWidgets.QAction('&About', self.window) + aboutAct.setStatusTip('About this application') + aboutAct.triggered.connect(self._showAbout) + helpMenu = menubar.addMenu('&Help') + helpMenu.addAction(aboutAct) + + + def _showAbout(self): + print("showDialog") + msg = QtWidgets.QMessageBox() + msg.setWindowTitle("About") + msg.setIconPixmap(QtGui.QPixmap(os.path.dirname(__file__) + "/Resources/Zeroconf.png")) + candidates = ["COPYRIGHT", "COPYING", "LICENSE"] + for candidate in candidates: + if os.path.exists(os.path.dirname(__file__) + "/" + candidate): + with open(os.path.dirname(__file__) + "/" + candidate, 'r') as file: + data = file.read() + msg.setDetailedText(data) + msg.setText("

Zeroconf

") + msg.setInformativeText( + "A simple utility to browse services on the network announced with Zeroconf

Grateful acknowledgement is made to Stuart Cheshire who pioneered this incredibly useful technology

https://github.com/helloSystem/Utilities") + msg.exec() + +if __name__ == "__main__": + + zb = ZeroconfBrowser() diff --git a/Utilities/Zeroconf.app/Zeroconf b/Utilities/Zeroconf.app/Zeroconf index ac4bc5d6..a10778bf 100755 --- a/Utilities/Zeroconf.app/Zeroconf +++ b/Utilities/Zeroconf.app/Zeroconf @@ -1,310 +1,3 @@ -#!/usr/bin/env python3.7 - - -# Simple Zeroconf browser written for FreeBSD in PyQt5 - - -# Copyright (c) 2020, Simon Peter -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -import os, sys, time - -try: - from PyQt5 import QtWidgets, QtGui, QtCore -except: - print("Could not import PyQt5. On FreeBSD, sudo pkg install py37-qt5-widgets") - -# https://stackoverflow.com/a/377028 -def which(program): - - def is_exe(fpath): - return os.path.isfile(fpath) and os.access(fpath, os.X_OK) - - fpath, fname = os.path.split(program) - if fpath: - if is_exe(program): - return program - else: - for path in os.environ["PATH"].split(os.pathsep): - exe_file = os.path.join(path, program) - if is_exe(exe_file): - return exe_file - - return None - - -class ZeroconfService(): - - def __init__(self, avahi_browse_line, browser): - self.browser = browser - parts = avahi_browse_line.split(";") - if len(parts) < 10: - return - self.interface = parts[1] - self.ip_version = parts[2] - self.name = parts[3] # .decode("utf-8", "strict") - self.service_type = parts[4] - self.domain = parts[5] - self.hostname_with_domain = parts[6] - self.address = parts[7] - self.port = parts[8] - self.txt = parts[9] - self.url = "%s://%s:%s" % (self.service_type.split("_")[1].split("-")[0].replace(".", ""), self.hostname_with_domain, self.port) - - def __repr__(self): - return "%s on %s:%s" % (self.service_type, self.hostname_with_domain, self.port) - - # Define here what we should do with detected services. This gets run whenever a service is added - def handle(self): - print("Handling %s", str(self)) - icon = "unknown" - if self.url.startswith("device"): - icon = "computer" - if self.url.startswith("ssh"): - icon = "terminal" - if self.url.startswith("sftp") or self.url.startswith("smb"): - icon = "folder" - if self.url.startswith("raop"): - # AirPlay - icon = "network-wireless" - if self.url.startswith("pulse"): - # PulseAudio - icon = "audio-card" - if self.url.startswith("scanner") or self.url.startswith("uscan"): - icon = "scanner" - if self.url.startswith("http"): - icon = "applications-internet" - if self.url.startswith("ipp") or self.url.startswith("print") or self.url.startswith("pdl"): - icon = "printer" - item = QtWidgets.QListWidgetItem(QtGui.QIcon.fromTheme(icon), self.url) - self.browser.list_widget.addItem(item) - - -class ZeroconfServices(): - - def __init__(self): - self.services = [] - - def add(self, service): - if service.ip_version == "IPv4": - print("Appending " + str(service)) - self.services.append(service) - service.handle() - else: - print("service.ip_version: %s" % service.ip_version) - print("Not appending since IPv6; TODO: Show those as well but check for duplicates") - - def remove(self, avahi_browse_line): - print("TODO: To be implemented: Remove the service from the list if certain criteria match") - for service in self.services: - print(service.service_type) - print(service.hostname_with_domain) - -class sshLogin(QtWidgets.QDialog): - def __init__(self, host=None, parent=None): - super(sshLogin, self).__init__(parent) - self.host = host - self.setWindowTitle("Username") - self.textName = QtWidgets.QLineEdit(self) - self.buttonLogin = QtWidgets.QPushButton('Connect', self) - self.buttonLogin.clicked.connect(self.handleLogin) - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.textName) - layout.addWidget(self.buttonLogin) - - def handleLogin(self): - self.accept() # Close dialog - # Launch ssh in QTerminal - proc = QtCore.QProcess() - args = ["QTerminal", "-e", "ssh", self.host, "-l", self.textName.text()] - try: - proc.startDetached("launch", args) - except: - print("Cannot launch browser") - return - -class ZeroconfBrowser(object): - - def __init__(self): - - self.app = QtWidgets.QApplication(sys.argv) - - # Window - self.window = QtWidgets.QMainWindow() - self.window.setWindowTitle('Connnect to Server') - self.window.setMinimumWidth(500) - self.window.setMinimumHeight(350) - self.window.closeEvent = self.quit - self.layout = QtWidgets.QVBoxLayout() - - # List - self.list_widget = QtWidgets.QListWidget() - self.list_widget.setAlternatingRowColors(True) - self.list_widget.itemDoubleClicked.connect(self.onDoubleClicked) - self.layout.addWidget(self.list_widget) - - # Label - # self.label = QtWidgets.QLabel() - # self.label.setText("This application is written in PyQt5. It is very easy to extend. Look inside the .app to see its source code.") - # self.label.setWordWrap(True) - # self.layout.addWidget(self.label) - - self._showMenu() - - widget = QtWidgets.QWidget() - widget.setLayout(self.layout) - self.window.setCentralWidget(widget) - self.window.show() - - self.services = ZeroconfServices() - self.ext_process = QtCore.QProcess() - self.long_running_function() - - sys.exit(self.app.exec_()) - - def quit(self, event): - sys.exit(0) - - def onDoubleClicked(self): - print("Double clicked") - row = self.list_widget.selectedIndexes()[0].row() - print(self.services.services[row].url) - - if self.services.services[row].url.startswith("http"): - - # Find out the browser - # TODO: Give preference to the default browser the user may have set in the system. - # user@FreeBSD$ xdg-settings get default-web-browser - # userapp-Firefox-59CXP0.desktop - # Then no one knows where that file actually is, nor what it Exec= line contains. xdg is so convoluted! - # user@FreeBSD$ LANG=C x-www-browser - # bash: x-www-browser: command not found - # - # browser_candidates = [os.environ.get("BROWSER"), "x-www-browser", "chrome", "chromium-browser", "google-chrome", "firefox", "iceweasel", "seamonkey", "mozilla", "epiphany", "konqueror"] - # for browser_candidate in browser_candidates: - # if browser_candidate != None: - # if which(browser_candidate) != None: - # browser = browser_candidate - # print("Using as browser: %s" % browser) - # break - - # Launch the browser - proc = QtCore.QProcess() - args = [self.services.services[row].url] - try: - proc.startDetached("xdg-open", args) - return - except: - print("Cannot launch browser") - return - elif self.services.services[row].url.startswith("scanner") or self.services.services[row].url.startswith("uscan"): - # Launch Xsane in the hope that it can do something with it - os.system("xsane") - if self.services.services[row].url.startswith("ipp") or self.services.services[row].url.startswith("print") or self.services.services[row].url.startswith("pdl"): - os.system("launch 'Print Settings'") - elif self.services.services[row].url.startswith("ssh"): - # Launch the browser - sshL = sshLogin(host=self.services.services[row].url) - if sshL.exec_() == QtWidgets.QDialog.Accepted: - print("Do something") - else: - reply = QtWidgets.QMessageBox.information( - self.window, - "To be implemented", - "Something needs to be done here with\n%s\nPull requests welcome!" % self.services.services[row].url, - QtWidgets.QMessageBox.Yes - ) - - def long_running_function(self): - self.ext_process.finished.connect(self.onProcessFinished) - self.ext_process.setProgram("avahi-browse") - self.ext_process.setArguments(["-arlp"]) - - try: - pid = self.ext_process.start() - print("avahi-browse started") - except: - self.showErrorPage("avahi-browse cannot be launched.") - return # Stop doing anything here - - - if self.ext_process.waitForStarted(-1): - while True: - QtWidgets.QApplication.processEvents() # Important trick so that the app stays responsive without the need for threading! - time.sleep(0.1) - while self.ext_process.canReadLine(): - # This is a really crude attempt to read line-wise. FIXME: Do better - line = str(self.ext_process.readLine()) - self.processLine(line) - - print("ERROR: We should never reach this!") - - def onProcessFinished(self): - print("onProcessFinished called") - - def processLine(self, line): - line = str(line).replace("b'", "").replace("\\n'", "") - print(line) - if line.startswith("="): - s = ZeroconfService(line, self) - self.services.add(s) - if line.startswith("-"): - s = ZeroconfService(line, self) - self.services.remove(line) - - def _showMenu(self): - exitAct = QtWidgets.QAction('&Quit', self.window) - exitAct.setShortcut('Ctrl+Q') - exitAct.setStatusTip('Exit application') - exitAct.triggered.connect(sys.exit, 0) - menubar = self.window.menuBar() - fileMenu = menubar.addMenu('&File') - fileMenu.addAction(exitAct) - aboutAct = QtWidgets.QAction('&About', self.window) - aboutAct.setStatusTip('About this application') - aboutAct.triggered.connect(self._showAbout) - helpMenu = menubar.addMenu('&Help') - helpMenu.addAction(aboutAct) - - - def _showAbout(self): - print("showDialog") - msg = QtWidgets.QMessageBox() - msg.setWindowTitle("About") - msg.setIconPixmap(QtGui.QPixmap(os.path.dirname(__file__) + "/Resources/Zeroconf.png")) - candidates = ["COPYRIGHT", "COPYING", "LICENSE"] - for candidate in candidates: - if os.path.exists(os.path.dirname(__file__) + "/" + candidate): - with open(os.path.dirname(__file__) + "/" + candidate, 'r') as file: - data = file.read() - msg.setDetailedText(data) - msg.setText("

Zeroconf

") - msg.setInformativeText( - "A simple utility to browse services on the network announced with Zeroconf

Grateful acknowledgement is made to Stuart Cheshire who pioneered this incredibly useful technology

https://github.com/helloSystem/Utilities") - msg.exec() - -if __name__ == "__main__": - - zb = ZeroconfBrowser() +#!/bin/sh +HERE="$(dirname "$(readlink -f "${0}")")" +exec "${HERE}/Resources/zeroconf.py" "$@" From adaea297d87fc24ac218fcec6e5ba3fbb82e3123 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 15:29:45 -0400 Subject: [PATCH 02/17] ZeroconfBrowser: Remove new-style class syntax In Python 2, there were old-style classes (`class Foo:`) and new-style classes (`class Foo(object):`). This is not necessary in Python 3. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index ac4bc5d6..03b4fdf7 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -145,7 +145,7 @@ def handleLogin(self): print("Cannot launch browser") return -class ZeroconfBrowser(object): +class ZeroconfBrowser: def __init__(self): From 1247a0119a663250fe2104afccfcb7dbaf018226 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 14:30:21 -0400 Subject: [PATCH 03/17] ZeroconfBrowser.quit(): call self.app.quit() not sys.exit() This means that the self.app.aboutToQuit signal will be processed, and the application can clean up after itself. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 03b4fdf7..06f0d2ff 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -185,7 +185,7 @@ def __init__(self): sys.exit(self.app.exec_()) def quit(self, event): - sys.exit(0) + self.app.quit() def onDoubleClicked(self): print("Double clicked") From 50820978d2dbd963073ee3f6d803056edaa1679e Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:32:00 -0400 Subject: [PATCH 04/17] ZeroconfService: Rewrite class A simple class representing a service, with some unused fields left out, and with no handle() method. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 49 ++++---------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 06f0d2ff..2d1cf573 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -56,51 +56,22 @@ def is_exe(fpath): class ZeroconfService(): + """ - def __init__(self, avahi_browse_line, browser): - self.browser = browser - parts = avahi_browse_line.split(";") - if len(parts) < 10: - return - self.interface = parts[1] - self.ip_version = parts[2] - self.name = parts[3] # .decode("utf-8", "strict") - self.service_type = parts[4] - self.domain = parts[5] - self.hostname_with_domain = parts[6] - self.address = parts[7] - self.port = parts[8] - self.txt = parts[9] + Represents one service. + + """ + + def __init__(self, name, service_type, hostname_with_domain, port): + self.name = name + self.service_type = service_type + self.hostname_with_domain = hostname_with_domain + self.port = port self.url = "%s://%s:%s" % (self.service_type.split("_")[1].split("-")[0].replace(".", ""), self.hostname_with_domain, self.port) def __repr__(self): return "%s on %s:%s" % (self.service_type, self.hostname_with_domain, self.port) - # Define here what we should do with detected services. This gets run whenever a service is added - def handle(self): - print("Handling %s", str(self)) - icon = "unknown" - if self.url.startswith("device"): - icon = "computer" - if self.url.startswith("ssh"): - icon = "terminal" - if self.url.startswith("sftp") or self.url.startswith("smb"): - icon = "folder" - if self.url.startswith("raop"): - # AirPlay - icon = "network-wireless" - if self.url.startswith("pulse"): - # PulseAudio - icon = "audio-card" - if self.url.startswith("scanner") or self.url.startswith("uscan"): - icon = "scanner" - if self.url.startswith("http"): - icon = "applications-internet" - if self.url.startswith("ipp") or self.url.startswith("print") or self.url.startswith("pdl"): - icon = "printer" - item = QtWidgets.QListWidgetItem(QtGui.QIcon.fromTheme(icon), self.url) - self.browser.list_widget.addItem(item) - class ZeroconfServices(): From b037d74174d3e5797327fc553d8799f6c6671d50 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:34:42 -0400 Subject: [PATCH 05/17] ZeroconfService: Add unit tests --- .../Resources/ZeroconfService_test.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 Utilities/Zeroconf.app/Resources/ZeroconfService_test.py diff --git a/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py b/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py new file mode 100644 index 00000000..8643c08a --- /dev/null +++ b/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py @@ -0,0 +1,16 @@ +import unittest +from zeroconf import ZeroconfService + +class TestCommandReader(unittest.TestCase): + def test_1(self): + service = ZeroconfService("foo server", "_http._tcp", "foo.local", "80") + + self.assertEqual(service.name, "foo server") + self.assertEqual(service.service_type, "_http._tcp") + self.assertEqual(service.hostname_with_domain, "foo.local") + self.assertEqual(service.port, "80") + self.assertEqual(service.url, "http://foo.local:80") + self.assertEqual(service.__repr__(), "_http._tcp on foo.local:80") + +if __name__ == "__main__": + unittest.main() From 7b118e727365cc3dae9b8ce51c58eff954dc3b54 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 10:23:18 -0400 Subject: [PATCH 06/17] ZeroconfService: Allow testing for equality between instances This will be useful when we want to remove services from the list or avoid adding duplicates. --- Utilities/Zeroconf.app/Resources/ZeroconfService_test.py | 5 +++++ Utilities/Zeroconf.app/Resources/zeroconf.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py b/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py index 8643c08a..279810b3 100644 --- a/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py +++ b/Utilities/Zeroconf.app/Resources/ZeroconfService_test.py @@ -12,5 +12,10 @@ def test_1(self): self.assertEqual(service.url, "http://foo.local:80") self.assertEqual(service.__repr__(), "_http._tcp on foo.local:80") + def test_equality(self): + service1 = ZeroconfService("foo server", "_http._tcp", "foo.local", "80") + service2 = ZeroconfService("foo server2", "_http._tcp2", "foo.local", "80") + self.assertEqual(service1, service2) + if __name__ == "__main__": unittest.main() diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 2d1cf573..f793c155 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -72,6 +72,9 @@ def __init__(self, name, service_type, hostname_with_domain, port): def __repr__(self): return "%s on %s:%s" % (self.service_type, self.hostname_with_domain, self.port) + def __eq__(self, other): + return self.url == other.url + class ZeroconfServices(): From a8091efde145b6e5219673c2843465702de69e9b Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:38:00 -0400 Subject: [PATCH 07/17] CommandReader: Add class It runs a command using QProcess and emits each line of the output through a pyqtSignal. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 44 +++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index f793c155..64585aa0 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -29,10 +29,11 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import os, sys, time +import os, sys, time, io try: from PyQt5 import QtWidgets, QtGui, QtCore + from PyQt5.QtCore import QObject, QProcess, pyqtSignal except: print("Could not import PyQt5. On FreeBSD, sudo pkg install py37-qt5-widgets") @@ -76,6 +77,47 @@ def __eq__(self, other): return self.url == other.url +class CommandReader(QObject): + """ + + Runs a program via QProcess, and passes each line of STDOUT to the `lines` + signal. + + """ + + lines = pyqtSignal(bytes) + + def __init__(self, parent, command, arguments): + super().__init__(parent) + self.command = command + self.arguments = arguments + + def kill(self): + self.process.kill() + + def start(self): + self.process = QProcess(self) + + self.buffer = io.BytesIO(b"") + self.process.readyReadStandardOutput.connect(self.handler) + self.process.start(self.command, self.arguments) + + def wait(self): + self.process.waitForFinished() + + def handler(self): + position = self.buffer.tell() + self.buffer.write(bytes(self.process.readAllStandardOutput())) + self.buffer.seek(position) + + for line in self.buffer.readlines(): + if line == b"": + if self.process.state() == QProcess.ProcessState.NotRunning: + self.kill() + break + self.lines.emit(line) + + class ZeroconfServices(): def __init__(self): From ee70c48a641bf2fe6af44de5197206a2c09b62e7 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:40:04 -0400 Subject: [PATCH 08/17] CommandReader: Add unit tests --- .../Resources/CommandReader_test.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Utilities/Zeroconf.app/Resources/CommandReader_test.py diff --git a/Utilities/Zeroconf.app/Resources/CommandReader_test.py b/Utilities/Zeroconf.app/Resources/CommandReader_test.py new file mode 100644 index 00000000..52c2865d --- /dev/null +++ b/Utilities/Zeroconf.app/Resources/CommandReader_test.py @@ -0,0 +1,54 @@ +import unittest +from zeroconf import CommandReader +from PyQt5.QtCore import QObject, QProcess + +class TestCommandReader(unittest.TestCase): + def setUp(self): + self.lines = [] + self.return_code = None + self.parent = QObject() + + def test_echo1(self): + cmd = CommandReader(self.parent, "echo", ["foo"]) + output = [] + cmd.lines.connect(output.append) + cmd.start() + cmd.wait() + self.assertEqual(output, [b"foo\n"]) + + def test_echo2(self): + cmd = CommandReader(self.parent, "echo", ["foo\nbar"]) + output = [] + cmd.lines.connect(output.append) + cmd.start() + cmd.wait() + self.assertEqual(output, [b"foo\n", b"bar\n"]) + + def test_echo_sleep1(self): + cmd = CommandReader(self.parent, "sh", ["-c", "echo foo && sleep 1 && echo bar"]) + output = [] + cmd.lines.connect(output.append) + cmd.start() + cmd.wait() + self.assertEqual(output, [b"foo\n", b"bar\n"]) + + def test_echo_sleep_false1(self): + cmd = CommandReader(self.parent, "sh", ["-c", "echo foo && sleep 1 && echo bar && false"]) + output = [] + cmd.lines.connect(output.append) + cmd.start() + cmd.wait() + self.assertEqual(output, [b"foo\n", b"bar\n"]) + + def test_kill1(self): + cmd = CommandReader(self.parent, "sh", ["-c", "echo foo && sleep 1 && echo bar"]) + def handler(line): + self.assertEqual(line, b"foo\n") + cmd.kill() + cmd.lines.connect(handler) + cmd.start() + cmd.wait() + self.assertEqual(cmd.process.state(), QProcess.ProcessState.NotRunning) + +if __name__ == "__main__": + unittest.main() From 7a114fbbc6dfca243c75ceb30a4dd43ea6b43bc3 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:41:13 -0400 Subject: [PATCH 09/17] ZeroconfDiscoverer: Add class This class discovers services on the local network by running `dns-sd`. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 95 +++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 64585aa0..0fb61e39 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -33,7 +33,7 @@ try: from PyQt5 import QtWidgets, QtGui, QtCore - from PyQt5.QtCore import QObject, QProcess, pyqtSignal + from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QThread except: print("Could not import PyQt5. On FreeBSD, sudo pkg install py37-qt5-widgets") @@ -118,6 +118,99 @@ def handler(self): self.lines.emit(line) +class ZeroconfDiscoverer(QThread): + """ + + Runs the `dns-sd` tool, creates a ZeroconfService for each service + discovered, and passes those services to a signal: service_added and + service_removed. + + It works like so: + + 1. `dns-sd -B _services._dns-sd._udp` is run to enumerate all service types + available on the local network. + + 2. `dns-sd -B $SERVICE_TYPE` is run to enumerate all services of the + specified type. + + 3. `dns-sd -L $NAME $SERVICE_TYPE $DOMAIN` is run to get more details on a + particular service. + + """ + + service_added = pyqtSignal(ZeroconfService) + service_removed = pyqtSignal(ZeroconfService) + + + def start(self): + # List all service types + cmd = CommandReader(self, "dns-sd", ["-B", "_services._dns-sd._udp"]) + cmd.lines.connect(self.type_line_handler) + cmd.start() + + def type_line_handler(self, line): + line = line.decode() + # Parse line + data = line.split() + if len(data) < 7: + return + if data[1] != "Add": + return + service_type = data[6] + + # List all services of this type + cmd = CommandReader(self, "dns-sd", ["-B", service_type]) + cmd.lines.connect(self.service_line_handler) + cmd.start() + + def service_line_handler(self, line): + line = line.decode() + # Parse line + data = line.split() + if len(data) < 7: + return + if data[1] == "A/R": + # Header line + return + adding = data[1] == "Add" + + name = " ".join(data[6:]) + service_type = data[5].strip(".") + domain = data[4].strip(".") + + # We need a closure here so that we can pass on name, service_type, and domain + def handler(line): + line = line.decode() + data = line.split() + + if len(data) < 7: + return False + if data[2] != "can" or data[3] != "be" or data[4] != "reached" or data[5] != "at": + return False + + hostname_with_domain, port = data[6].split(":") + hostname_with_domain = hostname_with_domain.strip(".") + + # Create a service + service = ZeroconfService(name, service_type, hostname_with_domain, port) + + # Pass the service to the appropriate handler + if adding: + self.service_added.emit(service) + else: + self.service_removed.emit(service) + + return True + + cmd = CommandReader(self, "dns-sd", ["-L", name, service_type, domain]) + def f(line): + ok = handler(line) + if ok: + cmd.kill() + cmd.lines.connect(f) + cmd.start() + + class ZeroconfServices(): def __init__(self): From a5049034c6066b416364b8f7b806b9901a6d49dc Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 14:31:14 -0400 Subject: [PATCH 10/17] ZeroconfDiscoverer: Clean up all QProcesses on quit This means the tool no longer leaves several `dns-sd` processes running in the background. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 0fb61e39..ebfcf2f8 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -33,7 +33,7 @@ try: from PyQt5 import QtWidgets, QtGui, QtCore - from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QThread + from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QThread, QCoreApplication except: print("Could not import PyQt5. On FreeBSD, sudo pkg install py37-qt5-widgets") @@ -141,11 +141,17 @@ class ZeroconfDiscoverer(QThread): service_added = pyqtSignal(ZeroconfService) service_removed = pyqtSignal(ZeroconfService) + def cleanUpOnQuit(self, cmd): + # Kill the command + QCoreApplication.instance().aboutToQuit.connect(cmd.kill) + # Then wait for it - the main loop won't exit until all commands have finished + QCoreApplication.instance().aboutToQuit.connect(cmd.wait) def start(self): # List all service types cmd = CommandReader(self, "dns-sd", ["-B", "_services._dns-sd._udp"]) cmd.lines.connect(self.type_line_handler) + self.cleanUpOnQuit(cmd) cmd.start() def type_line_handler(self, line): @@ -161,6 +167,7 @@ def type_line_handler(self, line): # List all services of this type cmd = CommandReader(self, "dns-sd", ["-B", service_type]) cmd.lines.connect(self.service_line_handler) + self.cleanUpOnQuit(cmd) cmd.start() def service_line_handler(self, line): @@ -208,6 +215,7 @@ def f(line): if ok: cmd.kill() cmd.lines.connect(f) + self.cleanUpOnQuit(cmd) cmd.start() From 02088b5de2646281f74b6c75017ad0b17330b4fb Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Mon, 22 Mar 2021 15:43:30 -0400 Subject: [PATCH 11/17] ZeroconfServices & ZeroconfBrowser: Hook them up The recently added/modified classes behave differently from the old ones - so hook things up. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 88 ++++++++++---------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index ebfcf2f8..3c2b99ec 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -221,17 +221,14 @@ def f(line): class ZeroconfServices(): - def __init__(self): + def __init__(self, browser): self.services = [] + self.browser = browser def add(self, service): - if service.ip_version == "IPv4": - print("Appending " + str(service)) - self.services.append(service) - service.handle() - else: - print("service.ip_version: %s" % service.ip_version) - print("Not appending since IPv6; TODO: Show those as well but check for duplicates") + print("Appending " + str(service)) + self.services.append(service) + self.handle(service) def remove(self, avahi_browse_line): print("TODO: To be implemented: Remove the service from the list if certain criteria match") @@ -239,6 +236,31 @@ def remove(self, avahi_browse_line): print(service.service_type) print(service.hostname_with_domain) + # Define here what we should do with detected services. This gets run whenever a service is added + def handle(self, service): + print("Handling %s", str(service)) + icon = "unknown" + if service.url.startswith("device"): + icon = "computer" + if service.url.startswith("ssh"): + icon = "terminal" + if service.url.startswith("sftp") or service.url.startswith("smb"): + icon = "folder" + if service.url.startswith("raop"): + # AirPlay + icon = "network-wireless" + if service.url.startswith("pulse"): + # PulseAudio + icon = "audio-card" + if service.url.startswith("scanner") or service.url.startswith("uscan"): + icon = "scanner" + if service.url.startswith("http"): + icon = "applications-internet" + if service.url.startswith("ipp") or service.url.startswith("print") or service.url.startswith("pdl"): + icon = "printer" + item = QtWidgets.QListWidgetItem(QtGui.QIcon.fromTheme(icon), service.url) + self.browser.list_widget.addItem(item) + class sshLogin(QtWidgets.QDialog): def __init__(self, host=None, parent=None): super(sshLogin, self).__init__(parent) @@ -295,9 +317,8 @@ def __init__(self): self.window.setCentralWidget(widget) self.window.show() - self.services = ZeroconfServices() - self.ext_process = QtCore.QProcess() - self.long_running_function() + self.services = ZeroconfServices(self) + self.start_worker() sys.exit(self.app.exec_()) @@ -354,42 +375,17 @@ def onDoubleClicked(self): QtWidgets.QMessageBox.Yes ) - def long_running_function(self): - self.ext_process.finished.connect(self.onProcessFinished) - self.ext_process.setProgram("avahi-browse") - self.ext_process.setArguments(["-arlp"]) + def start_worker(self): + self.worker = ZeroconfDiscoverer(None) + self.worker.service_added.connect(self.add_handler) + self.worker.service_removed.connect(self.remove_handler) + self.worker.start() - try: - pid = self.ext_process.start() - print("avahi-browse started") - except: - self.showErrorPage("avahi-browse cannot be launched.") - return # Stop doing anything here - - - if self.ext_process.waitForStarted(-1): - while True: - QtWidgets.QApplication.processEvents() # Important trick so that the app stays responsive without the need for threading! - time.sleep(0.1) - while self.ext_process.canReadLine(): - # This is a really crude attempt to read line-wise. FIXME: Do better - line = str(self.ext_process.readLine()) - self.processLine(line) - - print("ERROR: We should never reach this!") - - def onProcessFinished(self): - print("onProcessFinished called") - - def processLine(self, line): - line = str(line).replace("b'", "").replace("\\n'", "") - print(line) - if line.startswith("="): - s = ZeroconfService(line, self) - self.services.add(s) - if line.startswith("-"): - s = ZeroconfService(line, self) - self.services.remove(line) + def add_handler(self, service): + self.services.add(service) + + def remove_handler(self, service): + self.services.remove(service) def _showMenu(self): exitAct = QtWidgets.QAction('&Quit', self.window) From aa3046c6efd04072cba073443b2a0fcb38c741c9 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 10:31:25 -0400 Subject: [PATCH 12/17] ZeroconfServices: Do not add duplicates --- Utilities/Zeroconf.app/Resources/zeroconf.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 3c2b99ec..28439c98 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -227,8 +227,9 @@ def __init__(self, browser): def add(self, service): print("Appending " + str(service)) - self.services.append(service) - self.handle(service) + if service not in self.services: + self.services.append(service) + self.handle(service) def remove(self, avahi_browse_line): print("TODO: To be implemented: Remove the service from the list if certain criteria match") From acc287587934645f07a10a228f1f0486d36de5c4 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 11:03:25 -0400 Subject: [PATCH 13/17] ZeroconfServices: Handle removing items from the list I don't know how to remove a PyQt5 widget entirely, so I used setHidden(true). --- Utilities/Zeroconf.app/Resources/zeroconf.py | 27 ++++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 28439c98..77c6ff5c 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -225,21 +225,25 @@ def __init__(self, browser): self.services = [] self.browser = browser + def remove(self, service): + print("Removing " + str(service)) + + for i in range(len(self.services)): + s = self.services[i] + if s[0] == service: + s[1].setHidden(True) + self.services.pop(i) + break + + # Define here what we should do with detected services. This gets run whenever a service is added def add(self, service): print("Appending " + str(service)) - if service not in self.services: - self.services.append(service) - self.handle(service) - def remove(self, avahi_browse_line): - print("TODO: To be implemented: Remove the service from the list if certain criteria match") - for service in self.services: - print(service.service_type) - print(service.hostname_with_domain) + # Check for services already in the list + for s in self.services: + if s[0] == service: + return - # Define here what we should do with detected services. This gets run whenever a service is added - def handle(self, service): - print("Handling %s", str(service)) icon = "unknown" if service.url.startswith("device"): icon = "computer" @@ -260,6 +264,7 @@ def handle(self, service): if service.url.startswith("ipp") or service.url.startswith("print") or service.url.startswith("pdl"): icon = "printer" item = QtWidgets.QListWidgetItem(QtGui.QIcon.fromTheme(icon), service.url) + self.services.append((service, item)) self.browser.list_widget.addItem(item) class sshLogin(QtWidgets.QDialog): From 7d0ff8cef1bc62154712d5193e9fee5335e79c68 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Tue, 23 Mar 2021 15:00:45 -0400 Subject: [PATCH 14/17] ZeroconfBrowser.onDoubleClicked(): Fix to work with new self.services - self.services changed slightly in the previous commit, so the method has been updated to handle that. - DRY. --- Utilities/Zeroconf.app/Resources/zeroconf.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 77c6ff5c..3062eefa 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -334,9 +334,9 @@ def quit(self, event): def onDoubleClicked(self): print("Double clicked") row = self.list_widget.selectedIndexes()[0].row() - print(self.services.services[row].url) + service = self.services.services[row][0] - if self.services.services[row].url.startswith("http"): + if service.url.startswith("http"): # Find out the browser # TODO: Give preference to the default browser the user may have set in the system. @@ -356,28 +356,28 @@ def onDoubleClicked(self): # Launch the browser proc = QtCore.QProcess() - args = [self.services.services[row].url] + args = [service.url] try: proc.startDetached("xdg-open", args) return except: print("Cannot launch browser") return - elif self.services.services[row].url.startswith("scanner") or self.services.services[row].url.startswith("uscan"): + elif service.url.startswith("scanner") or service.url.startswith("uscan"): # Launch Xsane in the hope that it can do something with it os.system("xsane") - if self.services.services[row].url.startswith("ipp") or self.services.services[row].url.startswith("print") or self.services.services[row].url.startswith("pdl"): + if service.url.startswith("ipp") or service.url.startswith("print") or service.url.startswith("pdl"): os.system("launch 'Print Settings'") - elif self.services.services[row].url.startswith("ssh"): + elif service.url.startswith("ssh"): # Launch the browser - sshL = sshLogin(host=self.services.services[row].url) + sshL = sshLogin(host=service.url) if sshL.exec_() == QtWidgets.QDialog.Accepted: print("Do something") else: reply = QtWidgets.QMessageBox.information( self.window, "To be implemented", - "Something needs to be done here with\n%s\nPull requests welcome!" % self.services.services[row].url, + "Something needs to be done here with\n%s\nPull requests welcome!" % service.url, QtWidgets.QMessageBox.Yes ) From b842e661d28514db8597be583c96305bca16cb01 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Thu, 25 Mar 2021 10:18:56 -0400 Subject: [PATCH 15/17] ZeroconfServices: Add a doc string --- Utilities/Zeroconf.app/Resources/zeroconf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 3062eefa..707c4d06 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -220,6 +220,11 @@ def f(line): class ZeroconfServices(): + """ + + Adds and removes services from a QListWidget. + + """ def __init__(self, browser): self.services = [] From 1a1a85eecb1f028b743b358de7d8f7bc40926847 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Thu, 25 Mar 2021 10:19:08 -0400 Subject: [PATCH 16/17] sshLogin: Add a doc string --- Utilities/Zeroconf.app/Resources/zeroconf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 707c4d06..6b23a97b 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -273,6 +273,12 @@ def add(self, service): self.browser.list_widget.addItem(item) class sshLogin(QtWidgets.QDialog): + """ + + Requests a username before launching `ssh` inside `QTerminal`. + + """ + def __init__(self, host=None, parent=None): super(sshLogin, self).__init__(parent) self.host = host From d02d2959c0d0aa2df861ff0aba1f275748302801 Mon Sep 17 00:00:00 2001 From: Mallory Adams Date: Thu, 25 Mar 2021 10:19:23 -0400 Subject: [PATCH 17/17] ZeroconfBrowser: Add a doc string --- Utilities/Zeroconf.app/Resources/zeroconf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Utilities/Zeroconf.app/Resources/zeroconf.py b/Utilities/Zeroconf.app/Resources/zeroconf.py index 6b23a97b..9dd0be8c 100755 --- a/Utilities/Zeroconf.app/Resources/zeroconf.py +++ b/Utilities/Zeroconf.app/Resources/zeroconf.py @@ -302,6 +302,11 @@ def handleLogin(self): return class ZeroconfBrowser: + """ + + A Qt application for browsing local services. + + """ def __init__(self):