Repository: qutebrowser/qutebrowser

Issue:
Support directory in external file pickers
Although not exposed as [FileSelectionMode](https://doc.qt.io/qt-5/qwebenginepage.html#FileSelectionMode-enum), qtwebengine has a directory selection mode.
When this mode is selected, the external picker's directory selection option should be used.

See: https://github.com/qt/qtwebengine/blob/dev/src/core/file_picker_controller.h#L65-L70


Discussion:
I guess there's a reason it's not exposed? Do you have an example website where this is actually used?
Onedrive, Google Drive, major cloud storage service sites use this mode for their directory upload functions.

https://codereview.qt-project.org/c/qt/qtwebengine/+/74325
`// Chromium extension, not exposed as part of the public API for now.`
It's 7 years old comment, I don't think there is a real reason anymore.
Right, looks like the [`webkitdirectory` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/webkitdirectory) causes this to happen, and like you say it seems to be used commonly, despite [being non-standard](https://caniuse.com/mdn-api_htmlinputelement_webkitdirectory). I found a [demo page](https://codepen.io/monjer/pen/JKRLzM) as well.

The enum value is `2` and our `chooseFiles` overload gets called, so we could indeed implement support for it. @AckslD is this something you'd like to work on? Here's what I'd propose:

- Add a new `FileSelection` enum in `shared.py` with values `open_single`, `open_multiple` and `upload_folder` or so (let's not care about the `save` one [in the code](https://github.com/qt/qtwebengine/blob/dev/src/core/file_picker_controller.h#L65-L70), as I can't find a way to actually trigger it).
- Rewrite the existing code to use those enum values instead of a `multiple` boolean argument. It should probably have a dictionary mapping the QtWebEngine values to our enum, with `2` hardcoded as a key for the directory mode (and an appropriate comment).
- For future-proofing, it should log a warning and call the superclass implementation if it gets any value other than those three.
- Also add a new end2end test for `webkitdirectory`.
Sounds good @The-Compiler, I'll take a look at it
Also reported to Qt now: [[QTBUG-91489] Missing FileSelectionMode::FileSelectUploadFolder member - Qt Bug Tracker](https://bugreports.qt.io/browse/QTBUG-91489)

Resolving patch:
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -22,9 +22,10 @@
 import os
 import sys
 import html
+import enum
 import netrc
-from typing import Callable, Mapping, List, Optional, Iterable
 import tempfile
+from typing import Callable, Mapping, List, Optional, Iterable, Iterator
 
 from PyQt5.QtCore import QUrl, pyqtBoundSignal
 
@@ -391,20 +392,29 @@ def netrc_authentication(url, authenticator):
     return True
 
 
-def choose_file(multiple: bool) -> List[str]:
-    """Select file(s) for uploading, using external command defined in config.
+class FileSelectionMode(enum.Enum):
+    """Mode to use for file selectors in choose_file."""
+
+    single_file = enum.auto()
+    multiple_files = enum.auto()
+    folder = enum.auto()
+
+
+def choose_file(qb_mode: FileSelectionMode) -> List[str]:
+    """Select file(s)/folder for uploading, using external command defined in config.
 
     Args:
-        multiple: Should selecting multiple files be allowed.
+        qb_mode: File selection mode
 
     Return:
         A list of selected file paths, or empty list if no file is selected.
         If multiple is False, the return value will have at most 1 item.
     """
-    if multiple:
-        command = config.val.fileselect.multiple_files.command
-    else:
-        command = config.val.fileselect.single_file.command
+    command = {
+        FileSelectionMode.single_file: config.val.fileselect.single_file.command,
+        FileSelectionMode.multiple_files: config.val.fileselect.multiple_files.command,
+        FileSelectionMode.folder: config.val.fileselect.folder.command,
+    }[qb_mode]
     use_tmp_file = any('{}' in arg for arg in command[1:])
     if use_tmp_file:
         handle = tempfile.NamedTemporaryFile(
@@ -420,19 +430,19 @@ def choose_file(multiple: bool) -> List[str]:
             )
             return _execute_fileselect_command(
                 command=command,
-                multiple=multiple,
+                qb_mode=qb_mode,
                 tmpfilename=tmpfilename,
             )
     else:
         return _execute_fileselect_command(
             command=command,
-            multiple=multiple,
+            qb_mode=qb_mode,
         )
 
 
 def _execute_fileselect_command(
     command: List[str],
-    multiple: bool,
+    qb_mode: FileSelectionMode,
     tmpfilename: Optional[str] = None
 ) -> List[str]:
     """Execute external command to choose file.
@@ -462,8 +472,44 @@ def _execute_fileselect_command(
             message.error(f"Failed to open tempfile {tmpfilename} ({e})!")
             selected_files = []
 
-    if not multiple:
+    return list(_validated_selected_files(qb_mode=qb_mode, selected_files=selected_files))
+
+
+def _validated_selected_files(
+    qb_mode: FileSelectionMode,
+    selected_files: List[str],
+) -> Iterator[str]:
+    """Validates selected files if they are.
+
+        * Of correct type
+        * Of correct number
+        * Existent
+
+    Args:
+        qb_mode: File selection mode used
+        selected_files: files selected
+
+    Return:
+        List of selected files that pass the checks.
+    """
+    if qb_mode != FileSelectionMode.multiple_files:
         if len(selected_files) > 1:
-            message.warning("More than one file chosen, using only the first")
-            return selected_files[:1]
-    return selected_files
+            message.warning("More than one file/folder chosen, using only the first")
+            selected_files = selected_files[:1]
+    for selected_file in selected_files:
+        if not os.path.exists(selected_file):
+            message.warning(f"Ignoring non-existent file '{selected_file}'")
+            continue
+        if qb_mode == FileSelectionMode.folder:
+            if not os.path.isdir(selected_file):
+                message.warning(
+                    f"Expected folder but got file, ignoring '{selected_file}'"
+                )
+                continue
+        else:
+            if not os.path.isfile(selected_file):
+                message.warning(
+                    f"Expected file but got folder, ignoring '{selected_file}'"
+                )
+                continue
+        yield selected_file
diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py
--- a/qutebrowser/browser/webengine/webview.py
+++ b/qutebrowser/browser/webengine/webview.py
@@ -31,6 +31,20 @@
 from qutebrowser.utils import log, debug, usertypes
 
 
+_QB_FILESELECTION_MODES = {
+    QWebEnginePage.FileSelectOpen: shared.FileSelectionMode.single_file,
+    QWebEnginePage.FileSelectOpenMultiple: shared.FileSelectionMode.multiple_files,
+    # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-91489
+    #
+    # QtWebEngine doesn't expose this value from its internal
+    # FilePickerControllerPrivate::FileChooserMode enum (i.e. it's not included in
+    # the public QWebEnginePage::FileSelectionMode enum).
+    # However, QWebEnginePage::chooseFiles is still called with the matching value
+    # (2) when a file input with "webkitdirectory" is used.
+    QWebEnginePage.FileSelectionMode(2): shared.FileSelectionMode.folder,
+}
+
+
 class WebEngineView(QWebEngineView):
 
     """Custom QWebEngineView subclass with qutebrowser-specific features."""
@@ -250,7 +264,13 @@ def chooseFiles(
         handler = config.val.fileselect.handler
         if handler == "default":
             return super().chooseFiles(mode, old_files, accepted_mimetypes)
-
         assert handler == "external", handler
-        return shared.choose_file(
-            multiple=(mode == QWebEnginePage.FileSelectOpenMultiple))
+        try:
+            qb_mode = _QB_FILESELECTION_MODES[mode]
+        except KeyError:
+            log.webview.warning(
+                f"Got file selection mode {mode}, but we don't support that!"
+            )
+            return super().chooseFiles(mode, old_files, accepted_mimetypes)
+
+        return shared.choose_file(qb_mode=qb_mode)
diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py
--- a/qutebrowser/browser/webkit/webpage.py
+++ b/qutebrowser/browser/webkit/webpage.py
@@ -199,7 +199,7 @@ def chooseFile(self, parent_frame: QWebFrame, suggested_file: str) -> str:
             return super().chooseFile(parent_frame, suggested_file)
 
         assert handler == "external", handler
-        selected = shared.choose_file(multiple=False)
+        selected = shared.choose_file(qb_mode=shared.FileSelectionMode.single_file)
         if not selected:
             return ''
         else:
@@ -229,7 +229,7 @@ def _handle_multiple_files(self, info, files):
             return True
 
         assert handler == "external", handler
-        files.fileNames = shared.choose_file(multiple=True)
+        files.fileNames = shared.choose_file(shared.FileSelectionMode.multiple_files)
         return True
 
     def shutdown(self):


Test patch:
diff --git a/tests/end2end/data/fileselect.html b/tests/end2end/data/fileselect.html
--- a/tests/end2end/data/fileselect.html
+++ b/tests/end2end/data/fileselect.html
@@ -5,7 +5,7 @@
         <title>Testing fileselection</title>
         <script type="text/javascript">
             function onLoad() {
-                for (let id of ['#single_file', '#multiple_files']) {
+                for (let id of ['#single_file', '#multiple_files', '#folder']) {
                     const input = document.querySelector(id);
                     input.addEventListener('change', function (e) {
                         console.log(`Files: ${Array.from(input.files).map(f => f.name).join(", ")}`);
@@ -15,7 +15,17 @@
         </script>
     </head>
     <body onload="onLoad()">
-        <input type="file" id="single_file" name="single_file" accept=".txt">
-        <input type="file" id="multiple_files" name="multiple_files" accept=".txt" multiple>
+        <div>
+            <label for="single_file">Select single file</label>
+            <input type="file" id="single_file" name="single_file" accept=".txt">
+        </div>
+        <div>
+            <label for="multiple_files">Select multiple files</label>
+            <input type="file" id="multiple_files" value="Select multiple files" name="multiple_files" accept=".txt" multiple>
+        </div>
+        <div>
+            <label for="folder">Select single folder</label>
+            <input type="file" id="folder" value="Select single folder" name="folder" webkitdirectory>
+        </div>
     </body>
 </html>
diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature
--- a/tests/end2end/features/editor.feature
+++ b/tests/end2end/features/editor.feature
@@ -193,36 +193,36 @@ Feature: Opening external editors
 
     ## select single file
 
-    Scenario: Select one file with single command
+    Scenario: Select one file with single file command
         When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file
         And I open data/fileselect.html
         And I run :click-element id single_file
         Then the javascript message "Files: 1.txt" should be logged
 
-    Scenario: Select one file with single command that writes to stdout
+    Scenario: Select one file with single file command that writes to stdout
         When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to stdout
         And I open data/fileselect.html
         And I run :click-element id single_file
         Then the javascript message "Files: 1.txt" should be logged
 
-    Scenario: Select two files with single command
+    Scenario: Select two files with single file command
         When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" and writes to a temporary file
 
         And I open data/fileselect.html
         And I run :click-element id single_file
         Then the javascript message "Files: 1.txt" should be logged
-        And the warning "More than one file chosen, using only the first" should be shown
+        And the warning "More than one file/folder chosen, using only the first" should be shown
 
     ## select multiple files
 
-    Scenario: Select one file with multiple command
+    Scenario: Select one file with multiple files command
         When I setup a fake multiple_files fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file
 
         And I open data/fileselect.html
         And I run :click-element id multiple_files
         Then the javascript message "Files: 1.txt" should be logged
 
-    Scenario: Select two files with multiple command
+    Scenario: Select two files with multiple files command
         When I setup a fake multiple_files fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" and writes to a temporary file
 
         And I open data/fileselect.html
@@ -239,3 +239,44 @@ Feature: Opening external editors
         Then the javascript message "Files: 1.txt" should not be logged
         And the error "Failed to open tempfile *" should be shown
         And "Failed to delete tempfile *" should be logged with level error
+
+    ## Select non-existent file
+
+    Scenario: Select non-existent file
+        When I set fileselect.handler to external
+        When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/non-existent.txt" and writes to a temporary file
+        And I open data/fileselect.html
+        And I run :click-element id single_file
+        Then the javascript message "Files: non-existent.txt" should not be logged
+        And the warning "Ignoring non-existent file *non-existent.txt'" should be shown
+
+    ## Select folder when expecting file
+
+    Scenario: Select folder for file
+        When I set fileselect.handler to external
+        When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers" and writes to a temporary file
+        And I open data/fileselect.html
+        And I run :click-element id single_file
+        Then the javascript message "Files: *" should not be logged
+        And the warning "Expected file but got folder, ignoring *numbers'" should be shown
+
+    ## Select file when expecting folder
+
+    @qtwebkit_skip
+    Scenario: Select file for folder
+        When I set fileselect.handler to external
+        When I setup a fake folder fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file
+        And I open data/fileselect.html
+        And I run :click-element id folder
+        Then the javascript message "Files: 1.txt" should not be logged
+        And the warning "Expected folder but got file, ignoring *1.txt'" should be shown
+
+    ## Select folder
+
+    @qtwebkit_skip
+    Scenario: Select one folder with folder command
+        When I set fileselect.handler to external
+        And I setup a fake folder fileselector selecting "tests/end2end/data/backforward/" and writes to a temporary file
+        And I open data/fileselect.html
+        And I run :click-element id folder
+        Then the javascript message "Files: 1.txt, 2.txt, 3.txt" should be logged