diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index bdbd910db..49f37ae70 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -7,6 +7,7 @@ import re import sys import html +import io as _io import os.path import collections import functools @@ -813,7 +814,8 @@ class AbstractDownloadItem(QObject): if filename is None: # pragma: no cover log.downloads.error("No filename to open the download!") return - self.pdfjs_requested.emit(os.path.basename(filename), + dirname = os.path.basename(os.path.dirname(filename)) + self.pdfjs_requested.emit(os.path.join(dirname, os.path.basename(filename)), self.url()) def cancel_for_origin(self) -> bool: @@ -1373,8 +1375,8 @@ class TempDownloadManager: # Make sure that the filename is not too long suggested_name = utils.elide_filename(suggested_name, 50) # pylint: disable=consider-using-with - fobj = tempfile.NamedTemporaryFile(dir=tmpdir.name, delete=False, - suffix='_' + suggested_name) + tmpfiledir = tempfile.mkdtemp(dir=tmpdir.name) + fobj = _io.open(os.path.join(tmpfiledir, suggested_name), 'w+b') self.files.append(fobj) return fobj diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index fa7970e6d..43155206e 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -517,8 +517,6 @@ def qute_pdfjs(url: QUrl) -> _HandlerRet: filename = QUrlQuery(url).queryItemValue('filename') if not filename: raise UrlInvalidError("Missing filename") - if '/' in filename or os.sep in filename: - raise RequestDeniedError("Path separator in filename.") path = _pdf_path(filename) with open(path, 'rb') as f: diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index b221a70dc..4047c8b04 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -130,6 +130,23 @@ session.lazy_restore: default: false desc: Load a restored tab as soon as it takes focus. +session.save_when_close: + type: Bool + default: True + desc: >- + Whether to automatically save a session when it is closed. + +session.startup_sessions: + type: + name: List + valtype: String + none_ok: true + default: null + #TODO The default value doesn't seem to actually be set to None, so this doesn't work + desc: >- + Which sessions to load on startup. None will load the last saved sessions + from the sate file, if you want to not load any sessions, pass an empty list. + backend: type: name: String @@ -2665,6 +2682,7 @@ window.title_format: - current_title - title_sep - id + - session - scroll_pos - host - backend @@ -2672,7 +2690,8 @@ window.title_format: - current_url - protocol - audio - default: '{perc}{current_title}{title_sep}qutebrowser' + #TODO default should not display session, especially if no session is set + default: '[{session}] {perc}{current_title}{title_sep}qutebrowser' desc: | Format to use for the window title. The same placeholders like for `tabs.title.format` are defined. diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index e0938ae36..8fcca3501 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -241,6 +241,7 @@ class TabbedBrowser(QWidget): self._global_marks: MutableMapping[str, tuple[QPoint, QUrl]] = {} self.default_window_icon = self._window().windowIcon() self.is_private = private + self.session = "_nosession" self.tab_deque = TabDeque() config.instance.changed.connect(self._on_config_changed) quitter.instance.shutting_down.connect(self.shutdown) @@ -317,6 +318,9 @@ class TabbedBrowser(QWidget): return fields = self.widget.get_tab_fields(idx) fields['id'] = self._win_id + #TODO update on window session changed + #TODO don't leak _nosession into window title + fields['session'] = self.session title = title_format.format(**fields) # prevent hanging WMs and similar issues with giant URLs diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index b487fcd2c..eb99179b2 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -256,9 +256,35 @@ class SessionManager(QObject): data['history'].append(item_data) return data + def _save_window(self, *, win_id=None, with_history=True): + """Get a dict with data for single window and its tabs.""" + main_window = objreg.get('main-window', scope='window', window=win_id) + + # We could be in the middle of destroying a window here + if sip.isdeleted(main_window): + return None + + tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) + + win_data: _JsonType = {} + win_data['session'] = tabbed_browser.session + active_window = objects.qapp.activeWindow() + if getattr(active_window, 'win_id', None) == win_id: + win_data['active'] = True + win_data['geometry'] = bytes(main_window.saveGeometry()) + win_data['tabs'] = [] + if tabbed_browser.is_private: + win_data['private'] = True + for i, tab in enumerate(tabbed_browser.widgets()): + active = i == tabbed_browser.widget.currentIndex() + win_data['tabs'].append(self._save_tab(tab, active, + with_history=with_history)) + + return win_data + def _save_all(self, *, only_window=None, with_private=False, with_history=True): """Get a dict with data for all windows/tabs.""" - data: _JsonType = {'windows': []} + session_data: _JsonType = {} if only_window is not None: winlist: Iterable[int] = [only_window] else: @@ -267,30 +293,18 @@ class SessionManager(QObject): for win_id in sorted(winlist): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - main_window = objreg.get('main-window', scope='window', - window=win_id) - - # We could be in the middle of destroying a window here - if sip.isdeleted(main_window): - continue if tabbed_browser.is_private and not with_private: continue - win_data: _JsonType = {} - active_window = objects.qapp.activeWindow() - if getattr(active_window, 'win_id', None) == win_id: - win_data['active'] = True - win_data['geometry'] = bytes(main_window.saveGeometry()) - win_data['tabs'] = [] - if tabbed_browser.is_private: - win_data['private'] = True - for i, tab in enumerate(tabbed_browser.widgets()): - active = i == tabbed_browser.widget.currentIndex() - win_data['tabs'].append(self._save_tab(tab, active, - with_history=with_history)) - data['windows'].append(win_data) - return data + if tabbed_browser.session not in session_data: + session_data[tabbed_browser.session] = {'windows': []} # type: _JsonType + + win_data = self._save_window(win_id=win_id, with_history=with_history) + if win_data is not None: + session_data[tabbed_browser.session]['windows'].append(win_data) + + return session_data def _get_session_name(self, name): """Helper for save to get the name to save the session to. @@ -308,6 +322,13 @@ class SessionManager(QObject): name = 'default' return name + def _dump_session(self, path, data): + try: + with qtutils.savefile_open(path) as f: + utils.yaml_dump(data, f) + except (OSError, UnicodeEncodeError, yaml.YAMLError) as e: + raise SessionError(e) + def save(self, name, last_window=False, load_next_time=False, only_window=None, with_private=False, with_history=True): """Save a named session. @@ -325,35 +346,54 @@ class SessionManager(QObject): Return: The name of the saved session. """ - name = self._get_session_name(name) - path = self._get_session_path(name) - - log.sessions.debug("Saving session {} to {}...".format(name, path)) if last_window: - data = self._last_window_session - if data is None: + session_data = self._last_window_session + if session_data is None: log.sessions.error("last_window_session is None while saving!") return None else: - data = self._save_all(only_window=only_window, - with_private=with_private, - with_history=with_history) + session_data = self._save_all(only_window=only_window, + with_private=with_private, + with_history=with_history) + log.sessions.vdebug( # type: ignore[attr-defined] - "Saving data: {}".format(data)) - try: - with qtutils.savefile_open(path) as f: - utils.yaml_dump(data, f) - except (OSError, UnicodeEncodeError, yaml.YAMLError) as e: - raise SessionError(e) + "Saving data: {}".format(session_data)) + if name == default: + # save all active sessions by default + names = list(session_data.keys()) + # don't save sessionless windows (TODO should be configurable) + if '_nosession' in names: + names.remove('_nosession') + elif os.path.isabs(name): + _save_all_to(name) + if load_next_time: + configfiles.state['general']['session'] = name + return [name] + else: + # could possibly support `:session_save session1 session2`? + names = [name] + + # current session is not used atm (could be session of current active window) + for s in names: + path = self._get_session_path(s) + self._dump_session(path, session_data.get(s, { 'windows' : [] })) if load_next_time: - configfiles.state['general']['session'] = name - return name + configfiles.state['general']['session'] = ','.join(names) + return names + + def _save_all_to(self, name): + session_data = self._save_all() + data = {'windows': []} # type: _JsonType + for d in session_data.values(): + data['windows'].extend(d['windows']) + path = self._get_session_path(name) + self._dump_session(path, data) def _save_autosave(self): """Save the autosave session.""" try: - self.save('_autosave') + self._save_all_to('_autosave') except SessionError as e: log.sessions.error("Failed to save autosave session: {}".format(e)) @@ -372,6 +412,7 @@ class SessionManager(QObject): def save_last_window_session(self): """Temporarily save the session for the last closed window.""" + #TODO needs to be saved to correct session file and not break if current is None self._last_window_session = self._save_all() def _load_tab(self, new_tab, data): # noqa: C901 @@ -462,6 +503,7 @@ class SessionManager(QObject): private=win.get('private', None)) tabbed_browser = objreg.get('tabbed-browser', scope='window', window=window.win_id) + tabbed_browser.session = win.get('session') tab_to_focus = None for i, tab in enumerate(win['tabs']): new_tab = tabbed_browser.tabopen(background=False) @@ -566,6 +608,77 @@ def session_load(name: str, *, log.sessions.debug("Loaded & deleted session {}.".format(name)) +@cmdutils.register() +@cmdutils.argument('name', completion=miscmodels.session) +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +def session_window_set(name: str, *, + win_id: int = None) -> None: + """Set session for current window + + Args: + name: The name of the session. + """ + if not isinstance(name, Sentinel) and name.startswith('_') and not force: + raise cmdutils.CommandError("{} is an internal session, a window " + "cannot be assigned to it".format(name)) + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.session = name + #FIXME Probably not meant to call this from here + tabbed_browser._update_window_title("session") + +@cmdutils.register() +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +def session_window_unset(*, win_id: int = None) -> None: + """Unset session for current window """ + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.session = "_nosession" + #FIXME Probably not meant to call this from here + tabbed_browser._update_window_title("session") + + +@cmdutils.register() +@cmdutils.argument('name', completion=miscmodels.session) +@cmdutils.argument('win_id', value=cmdutils.Value.win_id) +def session_close(name: str = None, *, + save: bool = False, + nosave: bool = False, + force: bool = False, + win_id: int = None) -> None: + """Close a session. + + Args: + name: The name of the session. If not given the session of the + current window is closed. + save: Save the session before closing it + nosave: Don't save the session, overrides config.session.save_when_close + """ + if save and nosave: + raise cmdutils.CommandError("Both --save and --nosave have been given.") + + if name is not None and name.startswith('_') and not force: + raise cmdutils.CommandError("{} is an internal session, use --force " + "to close anyways.".format(name)) + elif name is None: + tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) + name = tabbed_browser.session + + log.sessions.vdebug("Closing session: {}".format(name)) + + if not nosave and (save or config.val.session.save_when_close): + #TODO also needs to handle private windows if required + session_manager.save(name) + + windows = list(objreg.window_registry.values()) + + for w in windows: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=w.win_id) + if tabbed_browser.session == name: + w.close() + + @cmdutils.register() @cmdutils.argument('name', completion=miscmodels.session) @cmdutils.argument('win_id', value=cmdutils.Value.win_id) @@ -601,18 +714,19 @@ def session_save(name: ArgType = default, *, assert not name.startswith('_') try: if only_active_window: - name = session_manager.save(name, only_window=win_id, - with_private=True, - with_history=not no_history) + names = session_manager.save(name, only_window=win_id, + with_private=True, + with_history=not no_history) else: - name = session_manager.save(name, with_private=with_private, + names = session_manager.save(name, with_private=with_private, with_history=not no_history) except SessionError as e: raise cmdutils.CommandError("Error while saving session: {}".format(e)) if quiet: - log.sessions.debug("Saved session {}.".format(name)) + log.sessions.debug("Saved sessions {}.".format(','.join(names))) else: - message.info("Saved session {}.".format(name)) + #TODO handle Sentinel + message.info("Saved session {}.".format(','.join(names))) @cmdutils.register() @@ -644,26 +758,34 @@ def load_default(name): Args: name: The name of the session to load, or None to read state file. """ - if name is None and session_manager.exists('_autosave'): - name = '_autosave' - elif name is None: + if name is not None: + names = name.split(',') + elif session_manager.exists('_autosave'): + names = ['_autosave'] + elif config.val.session.startup_sessions is not None: + names = config.val.session.startup_sessions + if not isinstance(names, list): + names = [names] + else: try: - name = configfiles.state['general']['session'] + names = configfiles.state['general']['session'].split(',') except KeyError: # No session given as argument and none in the session file -> # start without loading a session return - try: - session_manager.load(name) - except SessionNotFoundError: - message.error("Session {} not found!".format(name)) - except SessionError as e: - message.error("Failed to load session {}: {}".format(name, e)) + for name in names: + try: + session_manager.load(name) + except SessionNotFoundError: + message.error("Session {} not found!".format(name)) + except SessionError as e: + message.error("Failed to load session {}: {}".format(name, e)) try: del configfiles.state['general']['session'] except KeyError: pass # If this was a _restart session, delete it. + #TODO don't forget to handle this special session too if name == '_restart': session_manager.delete('_restart') diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py index 0591ddbbd..05b6dec58 100644 --- a/tests/unit/misc/test_sessions.py +++ b/tests/unit/misc/test_sessions.py @@ -141,7 +141,7 @@ class TestSaveAll: # FIXME can this ever actually happen? assert not objreg.window_registry data = sess_man._save_all() - assert not data['windows'] + assert not data @webengine_refactoring_xfail def test_no_active_window(self, sess_man, fake_window, stubs,