diff --git a/resources/images/dot_green.png b/resources/images/dot_green.png new file mode 100644 index 0000000000..c05376d7a7 Binary files /dev/null and b/resources/images/dot_green.png differ diff --git a/resources/images/dot_red.png b/resources/images/dot_red.png new file mode 100644 index 0000000000..88df5ccf15 Binary files /dev/null and b/resources/images/dot_red.png differ diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py index 26239b59e7..1466732169 100644 --- a/src/calibre/devices/interface.py +++ b/src/calibre/devices/interface.py @@ -514,6 +514,67 @@ class DevicePlugin(Plugin): ''' pass + # Dynamic control interface + # All of these methods are called on the device_manager thread + + def is_dynamically_controllable(self): + ''' + Called by the device manager when starting plugins. If this method returns + a string, then a) it supports the device manager's dynamic control + interface, and b) that name is to be used when talking to the plugin. + + This method must be called from the device_manager thread. + ''' + return None + + def start_plugin(self): + ''' + This method is called to start the plugin. The plugin should begin + to accept device connections however it does that. If the plugin is + already accepting connections, then do nothing. + + This method must be called from the device_manager thread. + ''' + pass + + def stop_plugin(self): + ''' + This method is called to stop the plugin. The plugin should no longer + accept connections, and should cleanup behind itself. It is likely that + this method should call shutdown. If the plugin is already not accepting + connections, then do nothing. + + This method must be called from the device_manager thread. + ''' + pass + + def get_option(self, opt_string, default=None): + ''' + Return the value of the option indicated by opt_string. This method can + be called when the plugin is not started. Return None if the option does + not exist. + + This method must be called from the device_manager thread. + ''' + return default + + def set_option(self, opt_string, opt_value): + ''' + Set the value of the option indicated by opt_string. This method can + be called when the plugin is not started. + + This method must be called from the device_manager thread. + ''' + pass + + def is_running(self): + ''' + Return True if the plugin is started, otherwise false + + This method must be called from the device_manager thread. + ''' + return False + class BookList(list): ''' A list of books. Each Book object must have the fields diff --git a/src/calibre/gui2/actions/device.py b/src/calibre/gui2/actions/device.py index 0ef06d59d5..92ed77e324 100644 --- a/src/calibre/gui2/actions/device.py +++ b/src/calibre/gui2/actions/device.py @@ -14,6 +14,7 @@ from calibre.utils.smtp import config as email_config from calibre.constants import iswindows, isosx from calibre.customize.ui import is_disabled from calibre.devices.bambook.driver import BAMBOOK +from calibre.gui2.dialogs.smartdevice import SmartdeviceDialog from calibre.gui2 import info_dialog class ShareConnMenu(QMenu): # {{{ @@ -24,6 +25,7 @@ class ShareConnMenu(QMenu): # {{{ config_email = pyqtSignal() toggle_server = pyqtSignal() + control_smartdevice = pyqtSignal() dont_add_to = frozenset(['context-menu-device']) def __init__(self, parent=None): @@ -56,6 +58,11 @@ class ShareConnMenu(QMenu): # {{{ _('Start Content Server')) self.toggle_server_action.triggered.connect(lambda x: self.toggle_server.emit()) + self.control_smartdevice_action = \ + self.addAction(QIcon(I('dot_green.png')), + _('Control Smart Device Connections')) + self.control_smartdevice_action.triggered.connect(lambda x: + self.control_smartdevice.emit()) self.addSeparator() self.email_actions = [] @@ -80,6 +87,9 @@ class ShareConnMenu(QMenu): # {{{ text = _('Stop Content Server') + ' [%s]'%get_external_ip() self.toggle_server_action.setText(text) + def hide_smartdevice_menus(self): + self.control_smartdevice_action.setVisible(False) + def build_email_entries(self, sync_menu): from calibre.gui2.device import DeviceAction for ac in self.email_actions: @@ -158,6 +168,7 @@ class ConnectShareAction(InterfaceAction): def genesis(self): self.share_conn_menu = ShareConnMenu(self.gui) self.share_conn_menu.toggle_server.connect(self.toggle_content_server) + self.share_conn_menu.control_smartdevice.connect(self.control_smartdevice) self.share_conn_menu.config_email.connect(partial( self.gui.iactions['Preferences'].do_config, initial_plugin=('Sharing', 'Email'))) @@ -200,8 +211,21 @@ class ConnectShareAction(InterfaceAction): if not self.stopping_msg.isVisible(): self.stopping_msg.exec_() return - - self.gui.content_server = None self.stopping_msg.accept() + def control_smartdevice(self): + sd_dialog = SmartdeviceDialog(self.gui) + sd_dialog.exec_() + self.set_smartdevice_icon() + + def check_smartdevice_menus(self): + if not self.gui.device_manager.is_enabled('smartdevice'): + self.share_conn_menu.hide_smartdevice_menus() + + def set_smartdevice_icon(self): + running = self.gui.device_manager.is_running('smartdevice') + if running: + self.share_conn_menu.control_smartdevice_action.setIcon(QIcon(I('dot_green.png'))) + else: + self.share_conn_menu.control_smartdevice_action.setIcon(QIcon(I('dot_red.png'))) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 17f1e47853..f08f87bf80 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -4,7 +4,7 @@ __copyright__ = '2008, Kovid Goyal ' # Imports {{{ import os, traceback, Queue, time, cStringIO, re, sys -from threading import Thread +from threading import Thread, Event from PyQt4.Qt import (QMenu, QAction, QActionGroup, QIcon, SIGNAL, Qt, pyqtSignal, QDialog, QObject, QVBoxLayout, @@ -30,6 +30,7 @@ from calibre.constants import DEBUG from calibre.utils.config import prefs, tweaks from calibre.utils.magick.draw import thumbnail from calibre.library.save_to_disk import find_plugboard +from calibre.gui2 import is_gui_thread # }}} class DeviceJob(BaseJob): # {{{ @@ -145,6 +146,10 @@ class DeviceManager(Thread): # {{{ self._device_information = None self.current_library_uuid = None self.call_shutdown_on_disconnect = False + self.devices_initialized = Event() + self.dynamic_plugins = {} + self.dynamic_plugin_requests = Queue.Queue(0) + self.dynamic_plugin_responses = Queue.Queue(0) def report_progress(self, *args): pass @@ -286,6 +291,10 @@ class DeviceManager(Thread): # {{{ # Do any device-specific startup processing. for d in self.devices: self.run_startup(d) + n = d.is_dynamically_controllable() + if n: + self.dynamic_plugins[n] = d + self.devices_initialized.set() while self.keep_going: kls = None @@ -309,6 +318,7 @@ class DeviceManager(Thread): # {{{ traceback.print_exc() else: self.detect_device() + while True: job = self.next() if job is not None: @@ -319,8 +329,15 @@ class DeviceManager(Thread): # {{{ self.current_job = None else: break - time.sleep(self.sleep_time) - + while True: + dynamic_method = None + try: + (dynamic_method, args, kwargs) = \ + self.dynamic_plugin_requests.get(timeout=self.sleep_time) + res = dynamic_method(*args, **kwargs) + self.dynamic_plugin_responses.put(res) + except Queue.Empty: + break # We are exiting. Call the shutdown method for each plugin for p in self.devices: try: @@ -508,6 +525,51 @@ class DeviceManager(Thread): # {{{ if self.connected_device: self.connected_device.set_driveinfo_name(location_code, name) + # dynamic plugin interface + + # This is a helper function that handles queueing with the device manager + def _queue_request(self, name, method, *args, **kwargs): + if not is_gui_thread(): + raise ValueError( + 'The device_manager dynamic plugin methods must be called from the GUI thread') + try: + d = self.dynamic_plugins.get(name, None) + if d: + self.dynamic_plugin_requests.put((getattr(d, method), args, kwargs)) + return self.dynamic_plugin_responses.get() + except: + traceback.print_exc() + return kwargs.get('default', None) + + # The dynamic plugin methods below must be called on the GUI thread. They + # will switch to the device thread before calling the plugin. + + def start_plugin(self, name): + self._queue_request(name, 'start_plugin') + + def stop_plugin(self, name): + self._queue_request(name, 'stop_plugin') + + def get_option(self, name, opt_string, default=None): + return self._queue_request(name, 'get_option', opt_string, default=default) + + def set_option(self, name, opt_string, opt_value): + self._queue_request(name, 'set_option', opt_string, opt_value) + + def is_running(self, name): + if self._queue_request(name, 'is_running'): + return True + return False + + def is_enabled(self, name): + try: + d = self.dynamic_plugins.get(name, None) + if d: + return True + except: + pass + return False + # }}} class DeviceAction(QAction): # {{{ @@ -708,6 +770,7 @@ class DeviceMixin(object): # {{{ self.job_manager, Dispatcher(self.status_bar.show_message), Dispatcher(self.show_open_feedback)) self.device_manager.start() + self.device_manager.devices_initialized.wait() if tweaks['auto_connect_to_folder']: self.connect_to_folder_named(tweaks['auto_connect_to_folder']) diff --git a/src/calibre/gui2/dialogs/smartdevice.py b/src/calibre/gui2/dialogs/smartdevice.py new file mode 100644 index 0000000000..15b40d1077 --- /dev/null +++ b/src/calibre/gui2/dialogs/smartdevice.py @@ -0,0 +1,81 @@ +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' +import re +from PyQt4.QtGui import QDialog, QLineEdit +from PyQt4.QtCore import SIGNAL, Qt + +from calibre.gui2.dialogs.smartdevice_ui import Ui_Dialog +from calibre.gui2 import dynamic + +class SmartdeviceDialog(QDialog, Ui_Dialog): + + def __init__(self, parent): + QDialog.__init__(self, parent) + Ui_Dialog.__init__(self) + self.setupUi(self) + + self.msg.setText( + _('This dialog starts and stops the smart device app interface. ' + 'When you start the interface, you might see some messages from ' + 'your computer\'s firewall or anti-virus manager asking you ' + 'if it is OK for calibre to connect to the network. Please ' + 'answer yes. If you do not, the app will not work. It will ' + 'be unable to connect to calibre.')) + + self.password_box.setToolTip('

' + + _('Use a password if calibre is running on a network that ' + 'is not secure. For example, if you run calibre on a laptop, ' + 'use that laptop in an airport, and want to connect your ' + 'smart device to calibre, you should use a password.') + '

') + + self.run_box.setToolTip('

' + + _('Check this box to allow calibre to accept connections from the ' + 'smart device. Uncheck the box to prevent connections.') + '

') + + self.autostart_box.setToolTip('

' + + _('Check this box if you want calibre to automatically start the ' + 'smart device interface when calibre starts. You should not do ' + 'this if you are using a network that is not secure and you ' + 'are not setting a password.') + '

') + self.connect(self.show_password, SIGNAL('stateChanged(int)'), self.toggle_password) + self.autostart_box.stateChanged.connect(self.autostart_changed) + + self.device_manager = parent.device_manager + if self.device_manager.is_running('smartdevice'): + self.run_box.setChecked(True) + else: + self.run_box.setChecked(False) + + if self.device_manager.get_option('smartdevice', 'autostart'): + self.autostart_box.setChecked(True) + self.run_box.setChecked(True) + self.run_box.setEnabled(False) + + pw = self.device_manager.get_option('smartdevice', 'password') + if pw: + self.password_box.setText(pw) + + def autostart_changed(self): + if self.autostart_box.isChecked(): + self.run_box.setChecked(True) + self.run_box.setEnabled(False) + else: + self.run_box.setEnabled(True) + + def toggle_password(self, state): + if state == Qt.Unchecked: + self.password_box.setEchoMode(QLineEdit.Password) + else: + self.password_box.setEchoMode(QLineEdit.Normal) + + def accept(self): + self.device_manager.set_option('smartdevice', 'password', + unicode(self.password_box.text())) + self.device_manager.set_option('smartdevice', 'autostart', + self.autostart_box.isChecked()) + if self.run_box.isChecked(): + self.device_manager.start_plugin('smartdevice') + else: + self.device_manager.stop_plugin('smartdevice') + + QDialog.accept(self) diff --git a/src/calibre/gui2/dialogs/smartdevice.ui b/src/calibre/gui2/dialogs/smartdevice.ui new file mode 100644 index 0000000000..9ac44b4dd1 --- /dev/null +++ b/src/calibre/gui2/dialogs/smartdevice.ui @@ -0,0 +1,137 @@ + + + Dialog + + + + 0 + 0 + 600 + 209 + + + + Smart device control + + + + :/images/mimetypes/unknown.png:/images/mimetypes/unknown.png + + + + + + &Automatically allow connections at startup + + + + + + + + 0 + 100 + + + + + + + + + 100 + 0 + + + + QLineEdit::Password + + + Optional password for security + + + + + + + &Allow connections + + + + + + + TextLabel + + + true + + + + + + + &Password: + + + password_box + + + + + + + &Show password + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index a597445f43..b414ef04dd 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -337,6 +337,15 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ if config['autolaunch_server']: self.start_content_server() + smartdevice_actions = self.iactions['Connect Share'] + smartdevice_actions.check_smartdevice_menus() + if self.device_manager.get_option('smartdevice', 'autostart'): + try: + self.device_manager.start_plugin('smartdevice') + except: + pass + smartdevice_actions.set_smartdevice_icon() + self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection) self.read_settings() diff --git a/src/calibre/utils/Zeroconf.py b/src/calibre/utils/Zeroconf.py index b722865101..1287148476 100755 --- a/src/calibre/utils/Zeroconf.py +++ b/src/calibre/utils/Zeroconf.py @@ -955,11 +955,16 @@ class Reaper(threading.Thread): return if globals()['_GLOBAL_DONE']: return - now = currentTimeMillis() - for record in self.zeroconf.cache.entries(): - if record.isExpired(now): - self.zeroconf.updateRecord(now, record) - self.zeroconf.cache.remove(record) + try: + # can get here in a race condition with shutdown. Swallow the + # exception and run around the loop again. + now = currentTimeMillis() + for record in self.zeroconf.cache.entries(): + if record.isExpired(now): + self.zeroconf.updateRecord(now, record) + self.zeroconf.cache.remove(record) + except: + pass class ServiceBrowser(threading.Thread):