Repository: qutebrowser/qutebrowser
Pull request: Add :prompt-accept --save for boolean prompts
Fixes #832

Missing tests as I'm running out of battery, a review of what's here already would be good!

Problem statement:
Permission answers not saved?
Might just be user error, but it seems that on close, sites for which I've enabled desktop notifications (like slack or gmail) forget that they're allowed to make desktop notifications: when I open back up and reopen those sites, they ask for notification permissions again.

Hints from issue discussion:
This is a missing feature - this information currently isn't saved.

I plan to look at this after per-domain settings (#27) are implemented, as then it should be very easy.

Stellar! I'm in no huge rush :+1: :)

For some reason this issue was not so annoying and started to be around date when #4063 was raised, does anyone have idea abot any workaround? 
There are various permission settings that take the values "true/false/ask". By default they are "ask". All of them support url patterns now so if you want to keep it at the default for all sites except a few which you want to allow or deny automatically you can do something like `:set -u https://some-trusted-site/* content.register_protocol_handler true` ([as per the docs](https://qutebrowser.org/doc/help/configuring.html#_per_domain_settings)).

The options that support an "ask" value are:
```
content.desktop_capture
content.geolocation
content.mouse_lock
content.media_capture
content.notifications
content.persistent_storage
content.register_protocol_handler
content.ssl_strict
```
Is it possible to handle per-site permissions in config.py, or do you have to use autoconfig.yml?
@WorMzy See [the relevant docs](https://github.com/qutebrowser/qutebrowser/blob/master/doc/help/configuring.asciidoc#per-domain-settings).
Perfect, thanks!

Commit message: Add :prompt-accept --save for boolean prompts

Fixes #832

Patch to review:
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index ae9ada9fdef..c34cfe602d4 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -1744,7 +1744,7 @@ How many blocks to move.
 
 [[prompt-accept]]
 === prompt-accept
-Syntax: +:prompt-accept ['value']+
+Syntax: +:prompt-accept [*--save*] ['value']+
 
 Accept the current prompt.
 
@@ -1752,6 +1752,9 @@ Accept the current prompt.
 * +'value'+: If given, uses this value instead of the entered one. For boolean prompts, "yes"/"no" are accepted as value.
 
 
+==== optional arguments
+* +*-s*+, +*--save*+: Save the value to the config.
+
 [[prompt-item-focus]]
 === prompt-item-focus
 Syntax: +:prompt-item-focus 'which'+
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 438a6c2b1e4..f6115b076d4 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -703,6 +703,8 @@ Default:
 * +pass:[&lt;Alt-Y&gt;]+: +pass:[prompt-yank]+
 * +pass:[&lt;Escape&gt;]+: +pass:[leave-mode]+
 * +pass:[&lt;Return&gt;]+: +pass:[prompt-accept]+
+* +pass:[N]+: +pass:[prompt-accept --save no]+
+* +pass:[Y]+: +pass:[prompt-accept --save yes]+
 * +pass:[n]+: +pass:[prompt-accept no]+
 * +pass:[y]+: +pass:[prompt-accept yes]+
 
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
index 194dc5b36f8..df71a981196 100644
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -156,7 +156,8 @@ def ignore_certificate_errors(url, errors, abort_on):
     Return:
         True if the error should be ignored, False otherwise.
     """
-    ssl_strict = config.instance.get('content.ssl_strict', url=url)
+    option = 'content.ssl_strict'
+    ssl_strict = config.instance.get(option, url=url)
     log.webview.debug("Certificate errors {!r}, strict {}".format(
         errors, ssl_strict))
 
@@ -177,7 +178,7 @@ def ignore_certificate_errors(url, errors, abort_on):
         urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
         ignore = message.ask(title="Certificate errors - continue?", text=msg,
                              mode=usertypes.PromptMode.yesno, default=False,
-                             abort_on=abort_on, url=urlstr)
+                             abort_on=abort_on, url=urlstr, option=option)
         if ignore is None:
             # prompt aborted
             ignore = False
@@ -225,7 +226,7 @@ def feature_permission(url, option, msg, yes_action, no_action, abort_on,
 
         if blocking:
             answer = message.ask(abort_on=abort_on, title='Permission request',
-                                 text=text, url=urlstr,
+                                 text=text, url=urlstr, option=option,
                                  mode=usertypes.PromptMode.yesno)
             if answer:
                 yes_action()
@@ -236,7 +237,8 @@ def feature_permission(url, option, msg, yes_action, no_action, abort_on,
             return message.confirm_async(
                 yes_action=yes_action, no_action=no_action,
                 cancel_action=no_action, abort_on=abort_on,
-                title='Permission request', text=text, url=urlstr)
+                title='Permission request', text=text, url=urlstr,
+                option=option)
     elif config_val:
         yes_action()
         return None
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 50b8221a1df..2a4800a11fe 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -2825,6 +2825,8 @@ bindings.default:
       <Return>: prompt-accept
       y: prompt-accept yes
       n: prompt-accept no
+      Y: prompt-accept --save yes
+      N: prompt-accept --save no
       <Alt-Y>: prompt-yank
       <Alt-Shift-Y>: prompt-yank --sel
       <Escape>: leave-mode
diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py
index 9fd3da8dd5d..9dfdd2d8a32 100644
--- a/qutebrowser/mainwindow/prompt.py
+++ b/qutebrowser/mainwindow/prompt.py
@@ -26,16 +26,17 @@
 
 import attr
 from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex,
-                          QItemSelectionModel, QObject, QEventLoop)
+                          QItemSelectionModel, QObject, QEventLoop, QUrl)
 from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit,
                              QLabel, QFileSystemModel, QTreeView, QSizePolicy,
                              QSpacerItem)
 
 from qutebrowser.browser import downloads
-from qutebrowser.config import config
+from qutebrowser.config import config, configtypes, configexc
 from qutebrowser.utils import usertypes, log, utils, qtutils, objreg, message
 from qutebrowser.keyinput import modeman
 from qutebrowser.api import cmdutils
+from qutebrowser.utils import urlmatch
 
 
 prompt_queue = None
@@ -55,7 +56,7 @@ class Error(Exception):
     """Base class for errors in this module."""
 
 
-class UnsupportedOperationError(Exception):
+class UnsupportedOperationError(Error):
 
     """Raised when the prompt class doesn't support the requested operation."""
 
@@ -377,7 +378,7 @@ def _on_global_mode_left(self, mode):
     @cmdutils.register(instance='prompt-container', scope='window',
                        modes=[usertypes.KeyMode.prompt,
                               usertypes.KeyMode.yesno])
-    def prompt_accept(self, value=None):
+    def prompt_accept(self, value=None, *, save=False):
         """Accept the current prompt.
 
         //
@@ -388,12 +389,15 @@ def prompt_accept(self, value=None):
         Args:
             value: If given, uses this value instead of the entered one.
                    For boolean prompts, "yes"/"no" are accepted as value.
+            save: Save the value to the config.
         """
         question = self._prompt.question
+
         try:
-            done = self._prompt.accept(value)
+            done = self._prompt.accept(value, save=save)
         except Error as e:
             raise cmdutils.CommandError(str(e))
+
         if done:
             message.global_bridge.prompt_done.emit(self._prompt.KEY_MODE)
             question.done()
@@ -545,7 +549,12 @@ def _init_key_label(self):
 
         self._vbox.addLayout(self._key_grid)
 
-    def accept(self, value=None):
+    def _check_save_support(self, save):
+        if save:
+            raise UnsupportedOperationError("Saving answers is only possible "
+                                            "with yes/no prompts.")
+
+    def accept(self, value=None, save=False):
         raise NotImplementedError
 
     def download_open(self, cmdline, pdfjs):
@@ -577,7 +586,8 @@ def __init__(self, question, parent=None):
         self.setFocusProxy(self._lineedit)
         self._init_key_label()
 
-    def accept(self, value=None):
+    def accept(self, value=None, save=False):
+        self._check_save_support(save)
         text = value if value is not None else self._lineedit.text()
         self.question.answer = text
         return True
@@ -693,7 +703,8 @@ def _init_fileview(self):
         self._file_model.directoryLoaded.connect(
             lambda: self._file_model.sort(0))
 
-    def accept(self, value=None):
+    def accept(self, value=None, save=False):
+        self._check_save_support(save)
         text = value if value is not None else self._lineedit.text()
         text = downloads.transform_path(text)
         if text is None:
@@ -763,8 +774,8 @@ def __init__(self, question, parent=None):
         super().__init__(question, parent)
         self._file_model.setFilter(QDir.AllDirs | QDir.Drives | QDir.NoDot)
 
-    def accept(self, value=None):
-        done = super().accept(value)
+    def accept(self, value=None, save=False):
+        done = super().accept(value, save)
         answer = self.question.answer
         if answer is not None:
             self.question.answer = downloads.FileDownloadTarget(answer)
@@ -817,7 +828,8 @@ def __init__(self, question, parent=None):
         assert not question.default, question.default
         self.setFocusProxy(self._user_lineedit)
 
-    def accept(self, value=None):
+    def accept(self, value=None, save=False):
+        self._check_save_support(save)
         if value is not None:
             if ':' not in value:
                 raise Error("Value needs to be in the format "
@@ -860,7 +872,14 @@ def __init__(self, question, parent=None):
         self._init_texts(question)
         self._init_key_label()
 
-    def accept(self, value=None):
+    def _check_save_support(self, save):
+        if self.question.option is None:
+            raise Error("No setting available to save the answer for this "
+                        "question.")
+
+    def accept(self, value=None, save=False):
+        self._check_save_support(save)
+
         if value is None:
             if self.question.default is None:
                 raise Error("No default value was set for this question!")
@@ -871,14 +890,30 @@ def accept(self, value=None):
             self.question.answer = False
         else:
             raise Error("Invalid value {} - expected yes/no!".format(value))
+
+        if save:
+            opt = config.instance.get_opt(self.question.option)
+            assert isinstance(opt.typ, configtypes.Bool)
+            pattern = urlmatch.UrlPattern(self.question.url)
+
+            try:
+                config.instance.set_obj(opt.name, self.question.answer,
+                                        pattern=pattern, save_yaml=True)
+            except configexc.Error as e:
+                raise Error(str(e))
+
         return True
 
     def _allowed_commands(self):
-        cmds = [
-            ('prompt-accept yes', "Yes"),
-            ('prompt-accept no', "No"),
-            ('prompt-yank', "Yank URL"),
-        ]
+        cmds = []
+
+        cmds.append(('prompt-accept yes', "Yes"))
+        if self.question.option is not None:
+            cmds.append(('prompt-accept --save yes', "Always"))
+
+        cmds.append(('prompt-accept no', "No"))
+        if self.question.option is not None:
+            cmds.append(('prompt-accept --save no', "Never"))
 
         if self.question.default is not None:
             assert self.question.default in [True, False]
@@ -886,6 +921,7 @@ def _allowed_commands(self):
             cmds.append(('prompt-accept', "Use default ({})".format(default)))
 
         cmds.append(('leave-mode', "Abort"))
+        cmds.append(('prompt-yank', "Yank URL"))
         return cmds
 
 
@@ -898,7 +934,8 @@ def __init__(self, question, parent=None):
         self._init_texts(question)
         self._init_key_label()
 
-    def accept(self, value=None):
+    def accept(self, value=None, save=False):
+        self._check_save_support(save)
         if value is not None:
             raise Error("No value is permitted with alert prompts!")
         # Simply mark prompt as done without setting self.question.answer
diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py
index b663cbfa407..155547abde1 100644
--- a/qutebrowser/utils/message.py
+++ b/qutebrowser/utils/message.py
@@ -83,7 +83,7 @@ def info(message: str, *, replace: bool = False) -> None:
 
 
 def _build_question(title, text=None, *, mode, default=None, abort_on=(),
-                    url=None):
+                    url=None, option=None):
     """Common function for ask/ask_async."""
     if not isinstance(mode, usertypes.PromptMode):
         raise TypeError("Mode {} is no PromptMode member!".format(mode))
@@ -93,6 +93,14 @@ def _build_question(title, text=None, *, mode, default=None, abort_on=(),
     question.mode = mode
     question.default = default
     question.url = url
+
+    if option is not None:
+        if mode != usertypes.PromptMode.yesno:
+            raise ValueError("Can only 'option' with PromptMode.yesno")
+        if url is None:
+            raise ValueError("Need 'url' given when 'option' is given")
+    question.option = option
+
     for sig in abort_on:
         sig.connect(question.abort)
     return question
@@ -106,6 +114,8 @@ def ask(*args, **kwargs):
         mode: A PromptMode.
         default: The default value to display.
         text: Additional text to show
+        option: The option for always/never question answers.
+                Only available with PromptMode.yesno.
         abort_on: A list of signals which abort the question if emitted.
 
     Return:
@@ -145,6 +155,7 @@ def confirm_async(*, yes_action, no_action=None, cancel_action=None,
         cancel_action: Callable to be called when the user cancelled the
                        question.
         default: True/False to set a default value, or None.
+        option: The option for always/never question answers.
         text: Additional text to show.
 
     Return:
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 03bd44869df..0bb7badc877 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -309,6 +309,7 @@ class Question(QObject):
         title: The question title to show.
         text: The prompt text to display to the user.
         url: Any URL referenced in prompts.
+        option: Boolean option to be set when answering always/never.
         answer: The value the user entered (as password for user_pwd).
         is_aborted: Whether the question was aborted.
         interrupted: Whether the question was interrupted by another one.
@@ -340,13 +341,15 @@ def __init__(self, parent=None):
         self.title = None
         self.text = None
         self.url = None
+        self.option = None
         self.answer = None
         self.is_aborted = False
         self.interrupted = False
 
     def __repr__(self):
         return utils.get_repr(self, title=self.title, text=self.text,
-                              mode=self._mode, default=self.default)
+                              mode=self._mode, default=self.default,
+                              option=self.option)
 
     @property
     def mode(self):


Code review comments:
diff_hunk: @@ -860,7 +872,14 @@ def __init__(self, question, parent=None):
         self._init_texts(question)
         self._init_key_label()
 
-    def accept(self, value=None):
+    def _check_save_support(self, save):
+        if self.question.option is None:
path: qutebrowser/mainwindow/prompt.py
text: @user1:
Should this be `if save and self.question.option is None:`?