From fe3ef9606f3c9e766c898e980f4e0dc08c0bae2e Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sun, 17 May 2020 23:33:36 +0100 Subject: [PATCH 1/7] WIP: Assign windows to sessions --- qutebrowser/config/configdata.yml | 4 +- qutebrowser/mainwindow/tabbedbrowser.py | 4 + qutebrowser/misc/sessions.py | 198 ++++++++++++++++++------ tests/unit/misc/test_sessions.py | 2 +- 4 files changed, 158 insertions(+), 50 deletions(-) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index ca84d45ea..23d75b9e7 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2651,6 +2651,7 @@ window.title_format: - current_title - title_sep - id + - session - scroll_pos - host - backend @@ -2658,7 +2659,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..697752ffa 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,46 @@ 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') + 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[s]) if load_next_time: - configfiles.state['general']['session'] = name + configfiles.state['general']['session'] = ','.join(names) return name def _save_autosave(self): """Save the autosave session.""" try: - self.save('_autosave') + 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('_autosave') + self._dump_session(path, data) except SessionError as e: log.sessions.error("Failed to save autosave session: {}".format(e)) @@ -372,6 +404,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 +495,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 +600,69 @@ 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 + +@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" + + +@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, + 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 + """ + 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)) + + #TODO config for save by default + if save: + #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) @@ -612,6 +709,7 @@ def session_save(name: ArgType = default, *, if quiet: log.sessions.debug("Saved session {}.".format(name)) else: + #TODO handle Sentinel message.info("Saved session {}.".format(name)) @@ -644,26 +742,30 @@ 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] + elif session_manager.exists('_autosave'): + names = ['_autosave'] + 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, From c99d6f5eabd49803f1bea54f1aeaa355b43012e6 Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sat, 23 May 2020 20:42:25 +0100 Subject: [PATCH 2/7] Default to saving session when closing (provide option to override) --- qutebrowser/config/configdata.yml | 6 ++++++ qutebrowser/misc/sessions.py | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 23d75b9e7..5d38725a7 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -130,6 +130,12 @@ 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. + backend: type: name: String diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 697752ffa..0632b0aa9 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -631,6 +631,7 @@ def session_window_unset(*, win_id: int = None) -> None: @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. @@ -639,7 +640,11 @@ def session_close(name: str = None, *, 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)) @@ -649,8 +654,7 @@ def session_close(name: str = None, *, log.sessions.vdebug("Closing session: {}".format(name)) - #TODO config for save by default - if save: + 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) From 088d25b6630dad161b873408f68858b48257ea9c Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sat, 23 May 2020 21:15:36 +0100 Subject: [PATCH 3/7] Set default startup sessions in config --- qutebrowser/config/configdata.yml | 11 +++++++++++ qutebrowser/misc/sessions.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 5d38725a7..79b1006b1 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -136,6 +136,17 @@ session.save_when_close: 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 diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 0632b0aa9..0bac10f2c 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -750,6 +750,10 @@ def load_default(name): names = [name] 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: names = configfiles.state['general']['session'].split(',') From 807edcf6ce49bdd281ac48360f77ef98a1bbcb97 Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sat, 23 May 2020 21:15:52 +0100 Subject: [PATCH 4/7] Allow restoring multiple sessions from cli via -r session1,session2 --- qutebrowser/misc/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 0bac10f2c..ac2441624 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -747,7 +747,7 @@ def load_default(name): name: The name of the session to load, or None to read state file. """ if name is not None: - names = [name] + names = name.split(',') elif session_manager.exists('_autosave'): names = ['_autosave'] elif config.val.session.startup_sessions is not None: From 7e5b696f459e560f326a5e8bdbc19eac16071591 Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sat, 23 May 2020 21:44:04 +0100 Subject: [PATCH 5/7] Support for saving all sessions to absolute path --- qutebrowser/misc/sessions.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index ac2441624..410f68531 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -364,6 +364,11 @@ class SessionManager(QObject): # 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] @@ -371,21 +376,24 @@ class SessionManager(QObject): # 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[s]) + self._dump_session(path, session_data.get(s, { 'windows' : [] })) if load_next_time: configfiles.state['general']['session'] = ','.join(names) - return name + 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: - 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('_autosave') - self._dump_session(path, data) + self._save_all_to('_autosave') except SessionError as e: log.sessions.error("Failed to save autosave session: {}".format(e)) @@ -702,19 +710,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: #TODO handle Sentinel - message.info("Saved session {}.".format(name)) + message.info("Saved session {}.".format(','.join(names))) @cmdutils.register() From eb06ac3f8b227f9d1484c1d132f806d933ab1a70 Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sat, 23 May 2020 21:44:27 +0100 Subject: [PATCH 6/7] Update window title when setting session --- qutebrowser/misc/sessions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 410f68531..eb99179b2 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -624,6 +624,8 @@ def session_window_set(name: str, *, 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) @@ -632,6 +634,8 @@ def session_window_unset(*, win_id: int = None) -> None: 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() From 2274dd2d391620874dafb9cb1f59155ee54b78be Mon Sep 17 00:00:00 2001 From: Samir Benmendil Date: Sun, 5 Mar 2023 20:12:23 +0000 Subject: [PATCH 7/7] Preserve filename in temporary downloads When downloading a file into a temporary location for external open or pdfjs, it used to prepend a random string to the filename. This means that downloading a file opened with pdfjs would have an unwanted mangled filename. Instead of adding a prefix to the filename, this puts the file with its filename intact in a unique temporary directory. --- qutebrowser/browser/downloads.py | 8 +++++--- qutebrowser/browser/qutescheme.py | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) 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 a6a4e8763..82784df1c 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -518,8 +518,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: