class MainWindow(QWidget): """The main window of qutebrowser. Adds all needed components to a vbox, initializes sub-widgets and connects signals. Attributes: status: The StatusBar widget. tabbed_browser: The TabbedBrowser widget. state_before_fullscreen: window state before activation of fullscreen. _downloadview: The DownloadView widget. _download_model: The DownloadModel instance. _vbox: The main QVBoxLayout. _commandrunner: The main CommandRunner instance. _overlays: Widgets shown as overlay for the current webpage. _private: Whether the window is in private browsing mode. """ # Application wide stylesheets STYLESHEET = """ HintLabel { background-color: {{ conf.colors.hints.bg }}; color: {{ conf.colors.hints.fg }}; font: {{ conf.fonts.hints }}; border: {{ conf.hints.border }}; border-radius: {{ conf.hints.radius }}px; padding-top: {{ conf.hints.padding['top'] }}px; padding-left: {{ conf.hints.padding['left'] }}px; padding-right: {{ conf.hints.padding['right'] }}px; padding-bottom: {{ conf.hints.padding['bottom'] }}px; } QMenu { {% if conf.fonts.contextmenu %} font: {{ conf.fonts.contextmenu }}; {% endif %} {% if conf.colors.contextmenu.menu.bg %} background-color: {{ conf.colors.contextmenu.menu.bg }}; {% endif %} {% if conf.colors.contextmenu.menu.fg %} color: {{ conf.colors.contextmenu.menu.fg }}; {% endif %} } QMenu::item:selected { {% if conf.colors.contextmenu.selected.bg %} background-color: {{ conf.colors.contextmenu.selected.bg }}; {% endif %} {% if conf.colors.contextmenu.selected.fg %} color: {{ conf.colors.contextmenu.selected.fg }}; {% endif %} } QMenu::item:disabled { {% if conf.colors.contextmenu.disabled.bg %} background-color: {{ conf.colors.contextmenu.disabled.bg }}; {% endif %} {% if conf.colors.contextmenu.disabled.fg %} color: {{ conf.colors.contextmenu.disabled.fg }}; {% endif %} } """ def __init__(self, *, private: bool, geometry: Optional[QByteArray] = None, parent: Optional[QWidget] = None) -> None: """Create a new main window. Args: geometry: The geometry to load, as a bytes-object (or None). private: Whether the window is in private browsing mode. parent: The parent the window should get. """ super().__init__(parent) self.setAttribute(Qt.WA_TranslucentBackground) # Late import to avoid a circular dependency # - browsertab -> hints -> webelem -> mainwindow -> bar -> browsertab from qutebrowser.mainwindow import tabbedbrowser from qutebrowser.mainwindow.statusbar import bar self.setAttribute(Qt.WA_DeleteOnClose) if config.val.window.transparent: self.setAttribute(Qt.WA_TranslucentBackground) self.palette().setColor(QPalette.Window, Qt.transparent) self._overlays: MutableSequence[_OverlayInfoType] = [] self.win_id = next(win_id_gen) self.registry = objreg.ObjectRegistry() objreg.window_registry[self.win_id] = self objreg.register('main-window', self, scope='window', window=self.win_id) tab_registry = objreg.ObjectRegistry() objreg.register('tab-registry', tab_registry, scope='window', window=self.win_id) self.setWindowTitle('qutebrowser') self._vbox = QVBoxLayout(self) self._vbox.setContentsMargins(0, 0, 0, 0) self._vbox.setSpacing(0) self._init_downloadmanager() self._downloadview = downloadview.DownloadView( model=self._download_model) self.is_private = config.val.content.private_browsing or private self.tabbed_browser: tabbedbrowser.TabbedBrowser = tabbedbrowser.TabbedBrowser( win_id=self.win_id, private=self.is_private, parent=self) objreg.register('tabbed-browser', self.tabbed_browser, scope='window', window=self.win_id) self._init_command_dispatcher() # We need to set an explicit parent for StatusBar because it does some # show/hide magic immediately which would mean it'd show up as a # window. self.status = bar.StatusBar(win_id=self.win_id, private=self.is_private, parent=self) self._add_widgets() self._downloadview.show() self._init_completion() log.init.debug("Initializing modes...") modeman.init(win_id=self.win_id, parent=self) self._commandrunner = runners.CommandRunner(self.win_id, partial_match=True) self._keyhint = keyhintwidget.KeyHintView(self.win_id, self) self._add_overlay(self._keyhint, self._keyhint.update_geometry) self._prompt_container = prompt.PromptContainer(self.win_id, self) self._add_overlay(self._prompt_container, self._prompt_container.update_geometry, centered=True, padding=10) objreg.register('prompt-container', self._prompt_container, scope='window', window=self.win_id, command_only=True) self._prompt_container.hide() self._messageview = messageview.MessageView(parent=self) self._add_overlay(self._messageview, self._messageview.update_geometry) self._xkb_switch = xkbswitch.XkbSwitchPlugin(win_id=self.win_id, parent=self) self._init_geometry(geometry) self._connect_signals() # When we're here the statusbar might not even really exist yet, so # resizing will fail. Therefore, we use singleShot QTimers to make sure # we defer this until everything else is initialized. QTimer.singleShot(0, self._connect_overlay_signals) config.instance.changed.connect(self._on_config_changed) objects.qapp.new_window.emit(self) self._set_decoration(config.val.window.hide_decoration) self.state_before_fullscreen = self.windowState() stylesheet.set_register(self) global i3ipc_used if i3ipc_used: try: self.wm_connection = WMConnection() response = self.wm_connection.get_version() self.wm_variant = 'i3' if 'variant' in response.ipc_data and response.ipc_data[ 'variant'] == 'sway': self.wm_variant = 'sway' except: i3ipc_used = False def _init_geometry(self, geometry): """Initialize the window geometry or load it from disk.""" if geometry is not None: self._load_geometry(geometry) elif self.win_id == 0: self._load_state_geometry() else: self._set_default_geometry() log.init.debug("Initial main window geometry: {}".format( self.geometry())) def _add_overlay(self, widget, signal, *, centered=False, padding=0): self._overlays.append((widget, signal, centered, padding)) def _update_overlay_geometries(self): """Update the size/position of all overlays.""" for w, _signal, centered, padding in self._overlays: self._update_overlay_geometry(w, centered, padding) def _update_overlay_geometry(self, widget, centered, padding): """Reposition/resize the given overlay.""" if not widget.isVisible(): return if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding: width = self.width() - 2 * padding if widget.hasHeightForWidth(): height = widget.heightForWidth(width) else: height = widget.sizeHint().height() left = padding else: size_hint = widget.sizeHint() width = min(size_hint.width(), self.width() - 2 * padding) height = size_hint.height() left = (self.width() - width) // 2 if centered else 0 height_padding = 20 status_position = config.val.statusbar.position if status_position == 'bottom': if self.status.isVisible(): status_height = self.status.height() bottom = self.status.geometry().top() else: status_height = 0 bottom = self.height() top = self.height() - status_height - height top = qtutils.check_overflow(top, 'int', fatal=False) topleft = QPoint(left, max(height_padding, top)) bottomright = QPoint(left + width, bottom) elif status_position == 'top': if self.status.isVisible(): status_height = self.status.height() top = self.status.geometry().bottom() else: status_height = 0 top = 0 topleft = QPoint(left, top) bottom = status_height + height bottom = qtutils.check_overflow(bottom, 'int', fatal=False) bottomright = QPoint(left + width, min(self.height() - height_padding, bottom)) else: raise ValueError("Invalid position {}!".format(status_position)) rect = QRect(topleft, bottomright) log.misc.debug('new geometry for {!r}: {}'.format(widget, rect)) if rect.isValid(): widget.setGeometry(rect) def _init_downloadmanager(self): log.init.debug("Initializing downloads...") qtnetwork_download_manager = objreg.get('qtnetwork-download-manager') try: webengine_download_manager = objreg.get( 'webengine-download-manager') except KeyError: webengine_download_manager = None self._download_model = downloads.DownloadModel( qtnetwork_download_manager, webengine_download_manager) objreg.register('download-model', self._download_model, scope='window', window=self.win_id, command_only=True) def _init_completion(self): self._completion = completionwidget.CompletionView(cmd=self.status.cmd, win_id=self.win_id, parent=self) completer_obj = completer.Completer(cmd=self.status.cmd, win_id=self.win_id, parent=self._completion) self._completion.selection_changed.connect( completer_obj.on_selection_changed) objreg.register('completion', self._completion, scope='window', window=self.win_id, command_only=True) self._add_overlay(self._completion, self._completion.update_geometry) def _init_command_dispatcher(self): # Lazy import to avoid circular imports from qutebrowser.browser import commands self._command_dispatcher = commands.CommandDispatcher( self.win_id, self.tabbed_browser) objreg.register('command-dispatcher', self._command_dispatcher, command_only=True, scope='window', window=self.win_id) widget = self.tabbed_browser.widget widget.destroyed.connect( functools.partial(objreg.delete, 'command-dispatcher', scope='window', window=self.win_id)) def __repr__(self): return utils.get_repr(self) @pyqtSlot(str) def _on_config_changed(self, option): """Resize the completion if related config options changed.""" if option == 'statusbar.padding': self._update_overlay_geometries() elif option == 'downloads.position': self._add_widgets() elif option == 'statusbar.position': self._add_widgets() self._update_overlay_geometries() elif option == 'window.hide_decoration': self._set_decoration(config.val.window.hide_decoration) def _add_widgets(self): """Add or readd all widgets to the VBox.""" self._vbox.removeWidget(self.tabbed_browser.widget) self._vbox.removeWidget(self._downloadview) self._vbox.removeWidget(self.status) widgets: List[QWidget] = [self.tabbed_browser.widget] downloads_position = config.val.downloads.position if downloads_position == 'top': widgets.insert(0, self._downloadview) elif downloads_position == 'bottom': widgets.append(self._downloadview) else: raise ValueError("Invalid position {}!".format(downloads_position)) status_position = config.val.statusbar.position if status_position == 'top': widgets.insert(0, self.status) elif status_position == 'bottom': widgets.append(self.status) else: raise ValueError("Invalid position {}!".format(status_position)) for widget in widgets: self._vbox.addWidget(widget) def _load_state_geometry(self): """Load the geometry from the state file.""" try: data = configfiles.state['geometry']['mainwindow'] geom = base64.b64decode(data, validate=True) except KeyError: # First start self._set_default_geometry() except binascii.Error: log.init.exception("Error while reading geometry") self._set_default_geometry() else: self._load_geometry(geom) def _save_geometry(self): """Save the window geometry to the state config.""" data = self.saveGeometry().data() geom = base64.b64encode(data).decode('ASCII') configfiles.state['geometry']['mainwindow'] = geom def _load_geometry(self, geom): """Load geometry from a bytes object. If loading fails, loads default geometry. """ log.init.debug("Loading mainwindow from {!r}".format(geom)) ok = self.restoreGeometry(geom) if not ok: log.init.warning("Error while loading geometry.") self._set_default_geometry() def _connect_overlay_signals(self): """Connect the resize signal and resize everything once.""" for widget, signal, centered, padding in self._overlays: signal.connect( functools.partial(self._update_overlay_geometry, widget, centered, padding)) self._update_overlay_geometry(widget, centered, padding) def _set_default_geometry(self): """Set some sensible default geometry.""" self.setGeometry(QRect(50, 50, 800, 600)) def _connect_signals(self): """Connect all mainwindow signals.""" mode_manager = modeman.instance(self.win_id) # misc self.tabbed_browser.close_window.connect(self.close) mode_manager.entered.connect(hints.on_mode_entered) # status bar mode_manager.hintmanager.set_text.connect(self.status.set_text) mode_manager.entered.connect(self.status.on_mode_entered) mode_manager.left.connect(self.status.on_mode_left) mode_manager.left.connect(self.status.cmd.on_mode_left) mode_manager.left.connect(message.global_bridge.mode_left) # xkbswitch mode_manager.entered.connect(self._xkb_switch.on_mode_entered) mode_manager.left.connect(self._xkb_switch.on_mode_left) # commands mode_manager.keystring_updated.connect( self.status.keystring.on_keystring_updated) self.status.cmd.got_cmd[str].connect(self._commandrunner.run_safely) self.status.cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) self.status.cmd.returnPressed.connect( self.tabbed_browser.on_cmd_return_pressed) self.status.cmd.got_search.connect(self._command_dispatcher.search) # key hint popup mode_manager.keystring_updated.connect(self._keyhint.update_keyhint) # messages message.global_bridge.show_message.connect( self._messageview.show_message) message.global_bridge.flush() message.global_bridge.clear_messages.connect( self._messageview.clear_messages) # statusbar self.tabbed_browser.current_tab_changed.connect( self.status.on_tab_changed) self.tabbed_browser.cur_progress.connect( self.status.prog.on_load_progress) self.tabbed_browser.cur_load_started.connect( self.status.prog.on_load_started) self.tabbed_browser.cur_scroll_perc_changed.connect( self.status.percentage.set_perc) self.tabbed_browser.widget.tab_index_changed.connect( self.status.tabindex.on_tab_index_changed) self.tabbed_browser.cur_url_changed.connect(self.status.url.set_url) self.tabbed_browser.cur_url_changed.connect( functools.partial(self.status.backforward.on_tab_cur_url_changed, tabs=self.tabbed_browser)) self.tabbed_browser.cur_link_hovered.connect( self.status.url.set_hover_url) self.tabbed_browser.cur_load_status_changed.connect( self.status.url.on_load_status_changed) self.tabbed_browser.cur_caret_selection_toggled.connect( self.status.on_caret_selection_toggled) self.tabbed_browser.cur_fullscreen_requested.connect( self._on_fullscreen_requested) self.tabbed_browser.cur_fullscreen_requested.connect( self.status.maybe_hide) # downloadview self.tabbed_browser.cur_fullscreen_requested.connect( self._downloadview.on_fullscreen_requested) # command input / completion mode_manager.entered.connect(self.tabbed_browser.on_mode_entered) mode_manager.left.connect(self.tabbed_browser.on_mode_left) self.status.cmd.clear_completion_selection.connect( self._completion.on_clear_completion_selection) self.status.cmd.hide_completion.connect(self._completion.hide) def _set_decoration(self, hidden): """Set the visibility of the window decoration via Qt.""" window_flags: int = Qt.Window refresh_window = self.isVisible() if hidden: window_flags |= Qt.CustomizeWindowHint | Qt.NoDropShadowWindowHint self.setWindowFlags(cast(Qt.WindowFlags, window_flags)) if refresh_window: self.show() @pyqtSlot(bool) def _on_fullscreen_requested(self, on): if not config.val.content.fullscreen.window: if on: self.state_before_fullscreen = self.windowState() self.setWindowState( Qt.WindowFullScreen | # type: ignore[arg-type] self.state_before_fullscreen) # type: ignore[operator] elif self.isFullScreen(): self.setWindowState(self.state_before_fullscreen) log.misc.debug('on: {}, state before fullscreen: {}'.format( on, debug.qflags_key(Qt, self.state_before_fullscreen))) @cmdutils.register(instance='main-window', scope='window') @pyqtSlot() def close(self): """Close the current window. // Extend close() so we can register it as a command. """ super().close() def resizeEvent(self, e): """Extend resizewindow's resizeEvent to adjust completion. Args: e: The QResizeEvent """ super().resizeEvent(e) self._update_overlay_geometries() self._downloadview.updateGeometry() self.tabbed_browser.widget.tabBar().refresh() def showEvent(self, e): """Extend showEvent to register us as the last-visible-main-window. Args: e: The QShowEvent """ super().showEvent(e) objreg.register('last-visible-main-window', self, update=True) def _confirm_quit(self): """Confirm that this window should be closed. Return: True if closing is okay, False if a closeEvent should be ignored. """ tab_count = self.tabbed_browser.widget.count() download_count = self._download_model.running_downloads() quit_texts = [] # Ask if multiple-tabs are open if 'multiple-tabs' in config.val.confirm_quit and tab_count > 1: quit_texts.append("{} tabs are open.".format(tab_count)) # Ask if multiple downloads running if 'downloads' in config.val.confirm_quit and download_count > 0: quit_texts.append("{} {} running.".format( download_count, "download is" if download_count == 1 else "downloads are")) # Process all quit messages that user must confirm if quit_texts or 'always' in config.val.confirm_quit: msg = jinja.environment.from_string(""" <ul> {% for text in quit_texts %} <li>{{text}}</li> {% endfor %} </ul> """.strip()).render(quit_texts=quit_texts) confirmed = message.ask('Really quit?', msg, mode=usertypes.PromptMode.yesno, default=True) # Stop asking if the user cancels if not confirmed: log.destroy.debug("Cancelling closing of window {}".format( self.win_id)) return False return True def closeEvent(self, e): """Override closeEvent to display a confirmation if needed.""" if crashsignal.crash_handler.is_crashing: e.accept() return if not self._confirm_quit(): e.ignore() return e.accept() for key in ['last-visible-main-window', 'last-focused-main-window']: try: win = objreg.get(key) if self is win: objreg.delete(key) except KeyError: pass sessions.session_manager.save_last_window_session() self._save_geometry() # Wipe private data if we close the last private window, but there are # still other windows if (self.is_private and len(objreg.window_registry) > 1 and len([ window for window in objreg.window_registry.values() if window.is_private ]) == 1): log.destroy.debug("Wiping private data before closing last " "private window") websettings.clear_private_data() log.destroy.debug("Closing window {}".format(self.win_id)) self.tabbed_browser.shutdown()
class Heimdall(QObject): """Heimdall service This service maintains a Raspberry Pi or similar device as an automatic display that works without user interaction. Connection timeline: 0. _setup_reestablish_tunnel - Set up a timer to try to reestablish the tunnel. This step is only performed if the tunnel was previously active and failed, and exist to provide a reconnection delay. 1. start_tunnel - Run SSH to the Raspberry pi 2. try_connect - Attempt to use the SSH connection to contact i3/Sway. If this fails, an automatic retry after a timeout is done. 3. setup - Take over the i3/Sway setup """ def __init__(self, parent=None): super().__init__(parent) self.reconnect_timer = QTimer() self.ssh_proc = QProcess() self.bus = QtDBus.QDBusConnection.sessionBus() self.dbus_adaptor = DBusAdaptor(self) self.contextual_executor = ContextualExecutor(self) if not self.bus.isConnected(): raise Exception("Failed to connect to dbus!") self.bus.registerObject("/heimdall", self) self.bus.registerService("com.troshchinskiy.Heimdall") self.homedir = os.environ['HOME'] + "/.heimdall" self.read_config() self.start_tunnel() def echo(self, text): return text def version(self): return "0.1" def connect(self): self.ssh = Popen(["ssh"], stdout=PIPE) def read_config(self): filename = self.homedir + '/config.json' print("Loading config file {}...\n".format(filename)) with open(filename, 'r') as conf_h: self.config = json.load(conf_h) def start_tunnel(self): if self.ssh_proc and self.ssh_proc.isOpen(): print("Tunnel already running") return print("Starting tunnel...\n") sway_pid = self._run_remote(["pidof", "sway"]) if sway_pid is None: raise Exception('Sway is not running!') home_dir = self._run_remote(["echo", '$HOME']) uid = self._run_remote(["echo", '$UID']) self.remote_socket = "/run/user/" + uid + "/sway-ipc." + uid + "." + sway_pid + ".sock" self.local_socket = self.homedir + "/sway.sock" print("Sway pid: '{}'".format(sway_pid)) print("Home dir: '{}'".format(home_dir)) print("UID : '{}'".format(uid)) print("Socket : '{}'".format(self.remote_socket)) if os.path.exists(self.local_socket): os.remove(self.local_socket) r = self.config['remote'] command_args = [ "-i", r['ssh-key'], "-p", r['port'], "-l", r['user'], "-R", r['backwards-port'] + ":127.0.0.1:" + r['local-ssh-port'], "-L", self.local_socket + ':' + self.remote_socket, r['server'] ] print("Running command: ssh {}".format(command_args)) self.ssh_proc.started.connect(self._ssh_process_started) self.ssh_proc.errorOccurred.connect(self._ssh_process_error) self.ssh_proc.finished.connect(self._ssh_process_finished) self.ssh_proc.start(self.config['commands']['ssh'], command_args) def try_connect(self): """Try to connect to i3/Sway. SSH takes a while to perform the port forwarding, so we may do this several times, until it starts working. """ print("Trying to connect to Sway/i3 at socket {}...".format( self.local_socket)) try: self.i3 = Connection(socket_path=self.local_socket) except ConnectionRefusedError: print("Not connected yet!") return except FileNotFoundError: print("Socket doesn't exist yet!!") return self.connect_timer.stop() self.setup() def setup(self): try: print("Setting up Sway/i3...") self.wm_version = self.i3.get_version() print("Connected to Sway/i3 version {}".format(self.wm_version)) print("Resetting workspace...") for workspace in self.i3.get_workspaces(): print("Deleting workspace {}".format(workspace.name)) self.i3.command('[workspace="{}"] kill'.format(workspace.name)) print("Executing commands...") for cmd in self.config['startup']['remote-run']: print("\tExecuting: {}".format(cmd)) self._run_remote(cmd) print("Setting up workspaces...") wsnum = 0 for wsconf in self.config['startup']['workspaces']: wsnum += 1 self.i3.command("workspace {}".format(wsnum)) self.i3.command('rename workspace "{}" to "{}"'.format( wsnum, wsconf['name'])) for wscmd in wsconf['commands']: self.i3_command(wscmd) except (ConnectionRefusedError, FileNotFoundError): self._setup_reestablish_tunnel() def i3_command(self, command): command = command.replace('$TERM_EXEC_KEEP', self.config['remote']['terminal-exec-keep']) command = command.replace('$TERM_EXEC', self.config['remote']['terminal-exec']) command = command.replace('$TERM', self.config['remote']['terminal']) command = command.replace( '$SSH_TO_HOST', self.config['commands']['ssh'] + " -p " + self.config['remote']['backwards-port'] + " -t " + os.environ['USER'] + '@localhost ') print("Executing command: " + command) self.i3.command(command) def contextual_action(self, environment, path, command): self.contextual_executor.execute(environment, path, command) def stop_tunnel(self): """Stop the tunnel, if it's running""" if self.ssh_proc and self.ssh_proc.isOpen(): print("Stopping ssh\n") self.ssh_proc.kill() self.ssh_proc.close() if os.path.exists(self.local_socket): os.remove(self.local_socket) def _setup_reestablish_tunnel(self): """Re-establish the SSH tunnel and begin again the process of syncing up""" self.stop_tunnel() self.reconnect_timer.timeout.connect(self.start_tunnel()) self.reconnect_timer.singleShot(True) self.reconnect_timer.start(100) def _ssh_process_started(self): print("SSH process started!") self.connect_timer = QTimer() self.connect_timer.timeout.connect(self.try_connect) self.connect_timer.start(50) def _ssh_process_error(self, error): print("SSH process failed with error {}!".format(error)) def _ssh_process_finished(self, exit_code, exit_status): print("SSH process exited with code {}, status {}!".format( exit_code, exit_status)) def _run_remote(self, command): r = self.config['remote'] ssh_command = [ self.config['commands']['ssh'], "-i", r['ssh-key'], "-p", r['port'], "-l", r['user'], r['server'] ] ssh_command += command print("Running: {}".format(ssh_command)) result_raw = subprocess.run(ssh_command, stdout=subprocess.PIPE) result = result_raw.stdout.decode('utf-8').strip() return result