diff --git a/src/calibre/gui2/viewer/config.py b/src/calibre/gui2/viewer/config.py index 6280e8385e..44446a878c 100644 --- a/src/calibre/gui2/viewer/config.py +++ b/src/calibre/gui2/viewer/config.py @@ -291,8 +291,9 @@ class ConfigDialog(QDialog, Ui_Dialog): def restore_defaults(self): opts = config('').parse() self.load_options(opts) - from calibre.gui2.viewer.main import dprefs + from calibre.gui2.viewer.main import dprefs, vprefs self.word_lookups = dprefs.defaults['word_lookups'] + self.opt_singleinstance.setChecked(vprefs.defaults['singleinstance']) def load_options(self, opts): self.opt_remember_window_size.setChecked(opts.remember_window_size) @@ -344,6 +345,8 @@ class ConfigDialog(QDialog, Ui_Dialog): setattr(self, 'current_%s_color'%x, getattr(opts, '%s_color'%x)) self.update_sample_colors() self.opt_show_controls.setChecked(opts.show_controls) + from calibre.gui2.viewer.main import vprefs + self.opt_singleinstance.setChecked(bool(vprefs['singleinstance'])) def change_color(self, which, reset=False): if reset: @@ -429,5 +432,6 @@ class ConfigDialog(QDialog, Ui_Dialog): c.set('show_controls', self.opt_show_controls.isChecked()) for x in ('top', 'bottom', 'side'): c.set(x+'_margin', int(getattr(self, 'opt_%s_margin'%x).value())) - from calibre.gui2.viewer.main import dprefs + from calibre.gui2.viewer.main import dprefs, vprefs dprefs['word_lookups'] = self.word_lookups + vprefs['singleinstance'] = self.opt_singleinstance.isChecked() diff --git a/src/calibre/gui2/viewer/config.ui b/src/calibre/gui2/viewer/config.ui index 269da7047d..8973643be4 100644 --- a/src/calibre/gui2/viewer/config.ui +++ b/src/calibre/gui2/viewer/config.ui @@ -68,7 +68,7 @@ QToolBox::tab:hover { 0 0 799 - 378 + 370 @@ -241,7 +241,7 @@ QToolBox::tab:hover { 0 0 799 - 378 + 370 @@ -414,8 +414,8 @@ QToolBox::tab:hover { 0 0 - 381 - 193 + 799 + 370 @@ -516,8 +516,8 @@ QToolBox::tab:hover { 0 0 - 340 - 70 + 799 + 370 @@ -595,8 +595,8 @@ QToolBox::tab:hover { 0 0 - 384 - 140 + 799 + 370 @@ -673,8 +673,8 @@ QToolBox::tab:hover { 0 0 - 479 - 226 + 799 + 370 @@ -688,20 +688,36 @@ QToolBox::tab:hover { - - - - Remember the &current page when quitting - - - Keep a copy of all bookmarks/current page information inside the ebook file, so that you can share them by simply sending the ebook file itself. Currently only works with ebooks in the EPUB format. - Keep a copy of bookmarks/current page inside the ebook file, for easy sharing + Keep a copy of &bookmarks/current page inside the ebook file, for easy sharing + + + + + + + Change the search engine used to perform online searches for selected text. +You must enter the search URL for the search engine, with the placeholder +{text}, which will be replaced by the selected text. + + + + + + + The default language to use for hyphenation rules. If the book does not specify a language, this will be used. + + + + + + + Remember the &current page when quitting @@ -719,7 +735,7 @@ QToolBox::tab:hover { - + Clear search history @@ -736,13 +752,6 @@ QToolBox::tab:hover { - - - - The default language to use for hyphenation rules. If the book does not specify a language, this will be used. - - - @@ -753,16 +762,7 @@ QToolBox::tab:hover { - - - - Change the search engine used to perform online searches for selected text. -You must enter the search URL for the search engine, with the placeholder -{text}, which will be replaced by the selected text. - - - - + Qt::Vertical @@ -775,6 +775,16 @@ You must enter the search URL for the search engine, with the placeholder + + + + Normally, you can view multiple books in calibre, each in its own viewer window. With this option, if you attempt to view a second book, it will replace the previously opened book instead of using a new window. + + + Allow only a &single book to be viewed at a time (needs restart) + + + diff --git a/src/calibre/gui2/viewer/main.py b/src/calibre/gui2/viewer/main.py index e780ede872..b7f21e3a83 100644 --- a/src/calibre/gui2/viewer/main.py +++ b/src/calibre/gui2/viewer/main.py @@ -8,7 +8,7 @@ from collections import namedtuple from PyQt5.Qt import ( QApplication, Qt, QIcon, QTimer, QByteArray, QSize, QTime, - QPropertyAnimation, QUrl, QInputDialog, QAction, QModelIndex) + QPropertyAnimation, QUrl, QInputDialog, QAction, QModelIndex, pyqtSignal) from calibre.gui2.viewer.ui import Main as MainWindow from calibre.gui2.viewer.toc import TOC @@ -20,14 +20,17 @@ from calibre.ebooks.oeb.iterator.book import EbookIterator from calibre.constants import islinux, filesystem_encoding from calibre.utils.config import Config, StringConfig, JSONConfig from calibre.customize.ui import available_input_formats -from calibre import as_unicode, force_unicode, isbytestring +from calibre import as_unicode, force_unicode, isbytestring, prints from calibre.ptempfile import reset_base_dir +from calibre.utils.ipc import viewer_socket_address, RC from calibre.utils.zipfile import BadZipfile from calibre.utils.localization import canonicalize_lang, lang_as_iso639_1, get_lang vprefs = JSONConfig('viewer') +vprefs.defaults['singleinstance'] = False dprefs = JSONConfig('viewer_dictionaries') dprefs.defaults['word_lookups'] = {} +singleinstance_name = 'calibre_viewer' class Worker(Thread): @@ -70,6 +73,19 @@ def lookup_website(lang): wm = dprefs['word_lookups'] return wm.get(lang, default_lookup_website(lang)) +def listen(self): + while True: + try: + conn = self.listener.accept() + except Exception: + break + try: + self.msg_from_anotherinstance.emit(conn.recv()) + conn.close() + except Exception as e: + prints('Failed to read message from other instance with error: %s' % as_unicode(e)) + self.listener = None + class EbookViewer(MainWindow): STATE_VERSION = 2 @@ -78,13 +94,20 @@ class EbookViewer(MainWindow): PAGED_MODE_TT = _('Switch to flow mode - where the text is not broken up ' 'into pages') AUTOSAVE_INTERVAL = 10 # seconds + msg_from_anotherinstance = pyqtSignal(object) def __init__(self, pathtoebook=None, debug_javascript=False, open_at=None, - start_in_fullscreen=False, continue_reading=False): + start_in_fullscreen=False, continue_reading=False, listener=None): MainWindow.__init__(self, debug_javascript) self.view.magnification_changed.connect(self.magnification_changed) self.closed = False self.show_toc_on_open = False + self.listener = listener + if listener is not None: + t = Thread(name='ConnListener', target=listen, args=(self,)) + t.daemon = True + t.start() + self.msg_from_anotherinstance.connect(self.another_instance_wants_to_talk, type=Qt.QueuedConnection) self.current_book_has_toc = False self.iterator = None self.current_page = None @@ -271,6 +294,8 @@ class EbookViewer(MainWindow): self.action_full_screen.trigger() return False self.save_state() + if self.listener is not None: + self.listener.close() return True def quit(self): @@ -858,6 +883,14 @@ class EbookViewer(MainWindow): except: traceback.print_exc() + def another_instance_wants_to_talk(self, msg): + try: + path, open_at = msg + except Exception: + return + self.load_ebook(path, open_at=open_at) + self.raise_() + def load_ebook(self, pathtoebook, open_at=None, reopen_at=None): if self.iterator is not None: self.save_current_position() @@ -1080,6 +1113,40 @@ View an ebook. setup_gui_option_parser(parser) return parser +def create_listener(): + if islinux: + from calibre.utils.ipc.server import LinuxListener as Listener + else: + from multiprocessing.connection import Listener + return Listener(address=viewer_socket_address()) + + +def ensure_single_instance(args, open_at): + try: + from calibre.utils.lock import singleinstance + si = singleinstance(singleinstance_name) + except Exception: + import traceback + error_dialog(None, _('Cannot start viewer'), _( + 'Failed to start viewer, single instance locking failed. Click "Show Details" for more information'), + det_msg=traceback.format_exc(), show=True) + raise SystemExit(1) + if not si: + if len(args) > 1: + t = RC(print_error=True, socket_address=viewer_socket_address()) + t.start() + t.join(3.0) + if t.is_alive() or t.conn is None: + error_dialog(None, _('Connect talk to viewer'), _( + 'Unable to connect to existing viewer window, try restarting it.'), show=True) + raise SystemExit(1) + t.conn.send((os.path.abspath(args[1]), open_at)) + t.conn.close() + prints('Opened book in existing viewer instance') + raise SystemExit(0) + listener = create_listener() + return listener + def main(args=sys.argv): # Ensure viewer can continue to function if GUI is closed @@ -1089,15 +1156,25 @@ def main(args=sys.argv): parser = option_parser() opts, args = parser.parse_args(args) open_at = float(opts.open_at.replace(',', '.')) if opts.open_at else None + listener = None override = 'calibre-ebook-viewer' if islinux else None app = Application(args, override_program_name=override, color_prefs=vprefs) app.load_builtin_fonts() app.setWindowIcon(QIcon(I('viewer.png'))) QApplication.setOrganizationName(ORG_NAME) QApplication.setApplicationName(APP_UID) + + if vprefs['singleinstance']: + try: + listener = ensure_single_instance(args, open_at) + except Exception as e: + import traceback + error_dialog(None, _('Failed to start viewer'), as_unicode(e), det_msg=traceback.format_exc(), show=True) + raise SystemExit(1) + main = EbookViewer(args[1] if len(args) > 1 else None, debug_javascript=opts.debug_javascript, open_at=open_at, continue_reading=opts.continue_reading, - start_in_fullscreen=opts.full_screen) + start_in_fullscreen=opts.full_screen, listener=listener) app.installEventFilter(main) # This is needed for paged mode. Without it, the first document that is # loaded will have extra blank space at the bottom, as @@ -1115,4 +1192,3 @@ def main(args=sys.argv): if __name__ == '__main__': sys.exit(main()) - diff --git a/src/calibre/utils/ipc/__init__.py b/src/calibre/utils/ipc/__init__.py index 109362a62a..dcbee7efac 100644 --- a/src/calibre/utils/ipc/__init__.py +++ b/src/calibre/utils/ipc/__init__.py @@ -11,7 +11,7 @@ from threading import Thread from calibre.constants import iswindows, get_windows_username, islinux -ADDRESS = None +ADDRESS = VADDRESS = None def eintr_retry_call(func, *args, **kwargs): while True: @@ -48,10 +48,39 @@ def gui_socket_address(): ADDRESS = os.path.join(tmp, user+'-calibre-gui.socket') return ADDRESS + +def viewer_socket_address(): + global VADDRESS + if VADDRESS is None: + if iswindows: + VADDRESS = r'\\.\pipe\CalibreViewer' + try: + user = get_windows_username() + except: + user = None + if user: + from calibre.utils.filenames import ascii_filename + user = ascii_filename(user).replace(' ', '_') + if user: + VADDRESS += '-' + user[:100] + 'x' + else: + user = os.environ.get('USER', '') + if not user: + user = os.path.basename(os.path.expanduser('~')) + if islinux: + VADDRESS = (u'\0%s-calibre-viewer.socket' % user).encode('ascii') + else: + from tempfile import gettempdir + tmp = gettempdir() + VADDRESS = os.path.join(tmp, user+'-calibre-viewer.socket') + return VADDRESS + + class RC(Thread): - def __init__(self, print_error=True): + def __init__(self, print_error=True, socket_address=None): self.print_error = print_error + self.socket_address = socket_address or gui_socket_address() Thread.__init__(self) self.conn = None self.daemon = True @@ -60,11 +89,9 @@ class RC(Thread): from multiprocessing.connection import Client self.done = False try: - self.conn = Client(gui_socket_address()) + self.conn = Client(self.socket_address) self.done = True except: if self.print_error: import traceback traceback.print_exc() - -