Skip to content

Commit

Permalink
Prompt for filesystem access
Browse files Browse the repository at this point in the history
Prompt the user to add filesystem permissions if Protontricks detects
any are missing. User can ignore the message, in which case the user
won't be asked again for the same paths later.
  • Loading branch information
Matoking committed Mar 18, 2022
1 parent b20b261 commit 236fc56
Show file tree
Hide file tree
Showing 9 changed files with 263 additions and 11 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).


## [Unreleased]
### Added
- Prompt the user to update Flatpak permissions if inaccessible paths are detected

### Removed
- Drop Python 3.5 support

Expand Down
8 changes: 7 additions & 1 deletion src/protontricks/cli/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path
from subprocess import run

from ..gui import select_steam_app_with_gui
from ..gui import prompt_filesystem_access, select_steam_app_with_gui
from ..steam import find_steam_path, get_steam_apps, get_steam_lib_paths
from .main import main as cli_main
from .util import (CustomArgumentParser, cli_error_handler, enable_logging,
Expand Down Expand Up @@ -116,6 +116,12 @@ def exit_(error):
# 2. Find any Steam library folders
steam_lib_paths = get_steam_lib_paths(steam_path)

# Check if Protontricks has access to all the required paths
prompt_filesystem_access(
paths=[steam_path, steam_root] + steam_lib_paths,
show_dialog=args.no_term
)

# 3. Find any Steam apps
steam_apps = get_steam_apps(
steam_root=steam_root, steam_path=steam_path,
Expand Down
8 changes: 7 additions & 1 deletion src/protontricks/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from .. import __version__
from ..flatpak import (FLATPAK_BWRAP_COMPATIBLE_VERSION,
get_running_flatpak_version)
from ..gui import select_steam_app_with_gui, show_text_dialog
from ..gui import prompt_filesystem_access, select_steam_app_with_gui
from ..steam import (find_legacy_steam_runtime_path, find_proton_app,
find_steam_path, get_steam_apps, get_steam_lib_paths)
from ..util import run_command
Expand Down Expand Up @@ -205,6 +205,12 @@ def exit_(error):
# 4. Find any Steam library folders
steam_lib_paths = get_steam_lib_paths(steam_path)

# Check if Protontricks has access to all the required paths
prompt_filesystem_access(
paths=[steam_path, steam_root] + steam_lib_paths,
show_dialog=args.no_term
)

# 5. Find any Steam apps
steam_apps = get_steam_apps(
steam_root=steam_root, steam_path=steam_path,
Expand Down
7 changes: 5 additions & 2 deletions src/protontricks/flatpak.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from pathlib import Path

import configparser
import json
import logging
import re
import shlex
from pathlib import Path

from .config import get_config

__all__ = (
"FLATPAK_BWRAP_COMPATIBLE_VERSION", "FLATPAK_INFO_PATH",
Expand Down
87 changes: 85 additions & 2 deletions src/protontricks/gui.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import functools
import itertools
import json
import logging
import os
import shlex
import shutil
import sys
from pathlib import Path
from subprocess import PIPE, CalledProcessError, run

import pkg_resources

__all__ = ("LocaleError", "select_steam_app_with_gui")
from .config import get_config
from .flatpak import get_inaccessible_paths

__all__ = (
"LocaleError", "get_gui_provider", "select_steam_app_with_gui",
"show_text_dialog", "prompt_filesystem_access"
)

logger = logging.getLogger("protontricks")

Expand Down Expand Up @@ -111,12 +119,14 @@ def _get_zenity_args():

return args

if get_gui_provider() == "yad":
gui_provider = get_gui_provider()
if gui_provider == "yad":
args = _get_yad_args()
else:
args = _get_zenity_args()

process = run(args, input=text.encode("utf-8"), check=False)

return process.returncode == 0


Expand Down Expand Up @@ -263,3 +273,76 @@ def run_gui(args, input_=None, strip_nonascii=False):
app for app in steam_apps
if app.appid == appid)
return steam_app


def prompt_filesystem_access(paths, show_dialog=False):
"""
Check whether Protontricks has access to the provided file system paths
and prompt the user to grant access if necessary.
:param show_dialog: Show a dialog. If disabled, just print the message
instead.
"""
config = get_config()

inaccessible_paths = get_inaccessible_paths(paths)
inaccessible_paths = set(map(str, inaccessible_paths))

# Check what paths the user has ignored previously
ignored_paths = set(
json.loads(config.get("Dialog", "DismissedPaths", "[]"))
)

# Remaining paths that are inaccessible and that haven't been dismissed
# by the user
remaining_paths = inaccessible_paths - ignored_paths

if not remaining_paths:
return None

cmd_filesystem = " ".join([
"--filesystem={}".format(shlex.quote(path))
for path in remaining_paths
])

# TODO: Showing a text dialog and asking user to manually run the command
# is very janky. Replace this with a proper permission prompt when
# Flatpak supports it.
message = (
"Protontricks does not appear to have access to the following "
"directories:\n"
" {paths}\n"
"\n"
"To fix this problem, grant access to the required directories by "
"copying the following command and running it in a terminal:\n"
"\n"
"flatpak override --user {cmd_filesystem} "
"com.github.Matoking.protontricks\n"
"\n"
"You will need to restart Protontricks for the settings to take "
"effect.".format(
paths=" ".join(remaining_paths),
cmd_filesystem=cmd_filesystem
)
)

if show_dialog:
ignore = show_text_dialog(
title="Protontricks",
text=message,
window_icon="wine",
cancel_label="Close",
ok_label="Ignore, don't ask again",
add_cancel_button=True
)

if ignore:
# If user clicked "Don't show again", store the paths to ensure the
# user isn't prompted again for these directories
ignored_paths |= inaccessible_paths

config.set(
"Dialog", "DismissedPaths", json.dumps(list(ignored_paths))
)

logger.warning(message)
22 changes: 22 additions & 0 deletions tests/cli/test_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,25 @@ def _mock_from_appmanifest(*args, **kwargs):
message = gui_provider.kwargs["input"]

assert b"Test appmanifest error" in message

@pytest.mark.usefixtures("flatpak_sandbox", "default_proton", "commands")
def test_run_filesystem_permission_missing(
self, launch_cli, steam_library_factory, steam_app_factory,
caplog):
"""
Try performing a launch command in a Flatpak sandbox where the user
hasn't provided adequate fileystem permissions. Ensure warning is
printed.
"""
steam_app_factory(name="Fake game 1", appid=10)
path = steam_library_factory(name="GameDrive")

launch_cli(["--appid", "10", "test.exe"])

record = next(
record for record in caplog.records
if "grant access to the required directories" in record.message
)
assert record.levelname == "WARNING"
assert str(path) in record.message

20 changes: 20 additions & 0 deletions tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,26 @@ def _mock_from_appmanifest(*args, **kwargs):

assert b"Test appmanifest error" in message

@pytest.mark.usefixtures("flatpak_sandbox")
def test_run_filesystem_permission_missing(
self, cli, steam_library_factory, caplog):
"""
Try performing a command in a Flatpak sandbox where the user
hasn't provided adequate fileystem permissions. Ensure warning is
printed.
"""
path = steam_library_factory(name="GameDrive")

cli(["-s", "fake"])

record = next(
record for record in caplog.records
if "grant access to the required directories" in record.message
)
assert record.levelname == "WARNING"
assert str(path) in record.message



class TestCLIGUI:
def test_run_gui(
Expand Down
13 changes: 10 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ def steam_root(steam_dir):
@pytest.fixture(scope="function")
def flatpak_sandbox(monkeypatch, tmp_path):
"""
Fake Flatpak sandbox running under Flatpak 1.12.1
Fake Flatpak sandbox running under Flatpak 1.12.1, with access to
the home directory
"""
flatpak_info_path = tmp_path / "flatpak-info"

Expand All @@ -110,7 +111,10 @@ def flatpak_sandbox(monkeypatch, tmp_path):
"name=fake.flatpak.Protontricks\n"
"\n"
"[Instance]\n"
"flatpak-version=1.12.1"
"flatpak-version=1.12.1\n"
"\n"
"[Context]\n"
"filesystems=home"
)

monkeypatch.setattr(
Expand Down Expand Up @@ -665,7 +669,10 @@ def mock_subprocess_run(args, **kwargs):
mock_gui_provider.args = args
mock_gui_provider.kwargs = kwargs

return MockResult(stdout=mock_gui_provider.mock_stdout.encode("utf-8"))
return MockResult(
stdout=mock_gui_provider.mock_stdout.encode("utf-8"),
returncode=mock_gui_provider.returncode
)

monkeypatch.setattr(
"protontricks.gui.run",
Expand Down
105 changes: 103 additions & 2 deletions tests/test_gui.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from subprocess import CalledProcessError

from protontricks.gui import select_steam_app_with_gui

import pytest
from conftest import MockResult

from protontricks.gui import (prompt_filesystem_access,
select_steam_app_with_gui)


@pytest.fixture(scope="function")
def broken_zenity(gui_provider, monkeypatch):
Expand Down Expand Up @@ -199,3 +200,103 @@ def test_select_game_gui_provider_env(
elif gui_cmd == "zenity":
assert gui_provider.args[0] == "zenity"
assert gui_provider.args[2] == "--hide-header"


@pytest.mark.usefixtures("flatpak_sandbox")
class TestPromptFilesystemAccess:
def test_prompt_without_desktop(self, home_dir, caplog):
"""
Test that calling 'prompt_filesystem_access' without showing the dialog
only generates a warning
"""
prompt_filesystem_access(
[home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"],
show_dialog=False
)

assert len(caplog.records) == 1

record = caplog.records[0]

assert record.levelname == "WARNING"
assert "Protontricks does not appear to have access" in record.message

assert "--filesystem=/mnt/fake_SSD" in record.message
assert "--filesystem=/mnt/fake_SSD_2" in record.message
assert str(home_dir / "fake_path") not in record.message

def test_prompt_with_desktop_no_dialog(self, home_dir, gui_provider):
"""
Test that calling 'prompt_filesystem_access' with 'show_dialog'
displays a dialog
"""
prompt_filesystem_access(
[home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"],
show_dialog=True
)

input_ = gui_provider.kwargs["input"].decode("utf-8")

assert str(home_dir / "fake_path") not in input_
assert "--filesystem=/mnt/fake_SSD" in input_
assert "--filesystem=/mnt/fake_SSD_2" in input_

def test_prompt_with_desktop_dialog(self, home_dir, gui_provider):
"""
Test that calling 'prompt_filesystem_access' with 'show_dialog'
displays a dialog
"""
# Mock the user closing the dialog without ignoring the messages
gui_provider.returncode = 1

prompt_filesystem_access(
[home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"],
show_dialog=True
)

input_ = gui_provider.kwargs["input"].decode("utf-8")

# Dialog was displayed
assert "/mnt/fake_SSD" in input_
assert "/mnt/fake_SSD_2" in input_

# Mock the user selecting "Ignore, don't ask again"
gui_provider.returncode = 0
gui_provider.kwargs["input"] = None

prompt_filesystem_access(
[home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"],
show_dialog=True
)

# Dialog is still displayed, but it won't be the next time
input_ = gui_provider.kwargs["input"].decode("utf-8")
assert "/mnt/fake_SSD" in input_
assert "/mnt/fake_SSD_2" in input_

gui_provider.kwargs["input"] = None

prompt_filesystem_access(
[home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"],
show_dialog=True
)

# Dialog is not shown, since the user has opted to ignore the warning
# for the current paths
assert not gui_provider.kwargs["input"]

# A new path makes the warning reappear
prompt_filesystem_access(
[
home_dir / "fake_path",
"/mnt/fake_SSD",
"/mnt/fake_SSD_2",
"/mnt/fake_SSD_3"
],
show_dialog=True
)

input_ = gui_provider.kwargs["input"].decode("utf-8")
assert "/mnt/fake_SSD " not in input_
assert "/mnt/fake_SSD_2" not in input_
assert "/mnt/fake_SSD_3" in input_

0 comments on commit 236fc56

Please sign in to comment.