Skip to content

Commit

Permalink
✨ Add Windows support via Registry (#9)
Browse files Browse the repository at this point in the history
* ✨ Add Windows support via Registry

* Convert int to str

* Bump version

* Refactor for coverage

* Add HKCU and additional browser names

* Add msedge-canary

* Prevent using shlex on Windows

* Catch exception when Registry key is not found
  • Loading branch information
roniemartinez authored Apr 21, 2022
1 parent 0bc990f commit c49ddea
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 26 deletions.
9 changes: 4 additions & 5 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
# os: [ ubuntu-latest, macos-latest, windows-latest ]
os: [ ubuntu-latest, macos-latest ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
python-version: [ '3.7', '3.8', '3.9', '3.10' ]
include:
- os: ubuntu-latest
Expand All @@ -31,9 +30,9 @@ jobs:
- os: macos-latest
pip-cache: ~/Library/Caches/pip
poetry-cache: ~/Library/Caches/pypoetry
# - os: windows-latest
# pip-cache: ~\AppData\Local\pip\Cache
# poetry-cache: ~\AppData\Local\pypoetry\Cache
- os: windows-latest
pip-cache: ~\AppData\Local\pip\Cache
poetry-cache: ~\AppData\Local\pypoetry\Cache
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@ pip install pybrowsers

- [x] Detect browser on OSX
- [x] Detect browser on Linux
- [ ] Detect browser on Windows
- [X] Detect browser on Windows
- [x] Launch browser with arguments
- [ ] Get browser by version (support wildcards)

## References

- [httptoolkit/browser-launcher](https://github.com/httptoolkit/browser-launcher)
- [Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/)
- [Github: webbrowser.open incomplete on Windows](https://github.com/python/cpython/issues/52479#issuecomment-1093496412)
- [Stackoverflow: Grabbing full file version of an exe in Python](https://stackoverflow.com/a/68774871/1279157)

## Author

- [Ronie Martinez](mailto:ronmarti18@gmail.com)
99 changes: 84 additions & 15 deletions browsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,28 @@
"/var/lib/snapd/desktop/applications",
)

WINDOWS_REGISTRY_BROWSER_NAMES = {
"Google Chrome": "chrome",
"Google Chrome Canary": "chrome-canary",
"Mozilla Firefox": "firefox",
"Firefox Developer Edition": "firefox-developer",
"Firefox Nightly": "firefox-nightly",
"Opera Stable": "opera",
"Opera beta": "opera-beta",
"Opera developer": "opera-developer",
"Microsoft Edge": "msedge",
"Microsoft Edge Beta": "msedge-beta",
"Microsoft Edge Dev": "msedge-dev",
"Microsoft Edge Canary": "msedge-canary",
"Internet Explorer": "msie",
"Brave": "brave",
"Brave Beta": "brave-beta",
"Brave Nightly": "brave-nightly",
}


def get_available_browsers() -> Iterator[Tuple[str, Dict]]:
platform = sys.platform
if platform == "linux":
if sys.platform == "linux":
from xdg.DesktopEntry import DesktopEntry

for browser, desktop_entries in LINUX_DESKTOP_ENTRY_LIST:
Expand All @@ -72,22 +90,70 @@ def get_available_browsers() -> Iterator[Tuple[str, Dict]]:
version = subprocess.getoutput(f"{executable_path} --version")
info = dict(path=executable_path, display_name=entry.getName(), version=version)
yield browser, info
return
if platform != "darwin": # pragma: no cover
elif sys.platform == "win32":
import winreg

yield from get_browsers_from_registry(winreg.HKEY_CURRENT_USER, winreg.KEY_READ)
yield from get_browsers_from_registry(winreg.HKEY_LOCAL_MACHINE, winreg.KEY_READ | winreg.KEY_WOW64_64KEY)
yield from get_browsers_from_registry(winreg.HKEY_LOCAL_MACHINE, winreg.KEY_READ | winreg.KEY_WOW64_32KEY)
elif sys.platform == "darwin":
for browser, bundle_id, version_string in OSX_BROWSER_BUNDLE_LIST:
paths = subprocess.getoutput(f'mdfind "kMDItemCFBundleIdentifier == {bundle_id}"').splitlines()
for path in paths:
with open(os.path.join(path, "Contents/Info.plist"), "rb") as f:
plist = plistlib.load(f)
display_name = plist.get("CFBundleDisplayName") or plist.get("CFBundleName", browser)
version = plist[version_string]
yield browser, dict(path=path, display_name=display_name, version=version)
else: # pragma: no cover
logger.info(
"'%s' is currently not supported. Please open an issue or a PR at '%s'",
platform,
sys.platform,
"https://github.com/roniemartinez/browsers",
)
return
for browser, bundle_id, version_string in OSX_BROWSER_BUNDLE_LIST:
paths = subprocess.getoutput(f'mdfind "kMDItemCFBundleIdentifier == {bundle_id}"').splitlines()
for path in paths:
with open(os.path.join(path, "Contents/Info.plist"), "rb") as f:
plist = plistlib.load(f)
display_name = plist.get("CFBundleDisplayName") or plist.get("CFBundleName", browser)
version = plist[version_string]
yield browser, dict(path=path, display_name=display_name, version=version)


def get_browsers_from_registry(tree: int, access: int) -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
if sys.platform == "win32":
import winreg

key = r"Software\Clients\StartMenuInternet"
try:
with winreg.OpenKey(tree, key, access=access) as hkey:
i = 0
while True:
try:
subkey = winreg.EnumKey(hkey, i)
i += 1
except OSError:
break
try:
name = winreg.QueryValue(hkey, subkey)
if not name or not isinstance(name, str):
name = subkey
except OSError:
name = subkey
try:
cmd = winreg.QueryValue(hkey, rf"{subkey}\shell\open\command")
cmd = cmd.strip('"')
os.stat(cmd)
except (OSError, AttributeError, TypeError, ValueError):
continue
info = dict(path=cmd, display_name=name, version=get_file_version(cmd))
yield WINDOWS_REGISTRY_BROWSER_NAMES.get(name, "unknown"), info
except FileNotFoundError:
pass


def get_file_version(path: str) -> Optional[str]:
if sys.platform == "win32":
import win32api

info = win32api.GetFileVersionInfo(path, "\\")
ms = info["FileVersionMS"]
ls = info["FileVersionLS"]
return ".".join(map(str, (win32api.HIWORD(ms), win32api.LOWORD(ms), win32api.HIWORD(ls), win32api.LOWORD(ls))))
return None


def get(browser: str) -> Optional[Dict]:
Expand All @@ -111,7 +177,10 @@ def _launch(browser: str, path: str, url: str, args: Sequence[str]) -> None: #
url_arg = [] if browser == "firefox" else [url]
if browser == "firefox":
args = ("-new-tab", url, *args)
command = [*shlex.split(path), *url_arg, "--args", *args]
if sys.platform == "win32":
command = [path, *url_arg, "--args", *args]
else:
command = [*shlex.split(path), *url_arg, "--args", *args]
if sys.platform == "darwin":
command = ["open", "--wait-apps", "--new", "--fresh", "-a", *command]
subprocess.Popen(command)
24 changes: 23 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pybrowsers"
version = "0.1.0-alpha.4"
version = "0.1.0-alpha.5"
repository = "https://github.com/roniemartinez/browsers"
description = "Python library for detecting and launching browsers"
authors = ["Ronie Martinez <ronmarti18@gmail.com>"]
Expand Down Expand Up @@ -29,6 +29,7 @@ packages = [
[tool.poetry.dependencies]
python = "^3.7"
pyxdg = { version = "^0.27", markers = "sys_platform == 'linux'" }
pywin32 = { version = "^303", markers = "sys_platform == 'win32'" }

[tool.poetry.dev-dependencies]
autoflake = "^1.3.1"
Expand Down
54 changes: 51 additions & 3 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
pytest.param("chrome", id="chrome"),
pytest.param("firefox", id="firefox"),
pytest.param("safari", id="safari", marks=pytest.mark.skipif(sys.platform != "darwin", reason="osx-only")),
pytest.param("msedge", id="msedge", marks=pytest.mark.skipif(sys.platform != "darwin", reason="osx-only")),
pytest.param(
"msedge", id="msedge", marks=pytest.mark.skipif(sys.platform == "linux", reason="osx-and-windows-only")
),
pytest.param("msie", id="msie", marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only")),
),
)
def test_get_available_browsers(browser: str) -> None:
Expand Down Expand Up @@ -57,8 +60,8 @@ def test_get_available_browsers(browser: str) -> None:
"path": "/Applications/Safari.app",
"version": ANY,
},
id="safari-osx",
marks=pytest.mark.skipif(sys.platform != "darwin", reason="osx-only"),
id="safari-osx",
),
pytest.param(
"msedge",
Expand All @@ -67,8 +70,8 @@ def test_get_available_browsers(browser: str) -> None:
"path": "/Applications/Microsoft Edge.app",
"version": ANY,
},
id="msedge-osx",
marks=pytest.mark.skipif(sys.platform != "darwin", reason="osx-only"),
id="msedge-osx",
),
pytest.param(
"chrome",
Expand All @@ -90,6 +93,46 @@ def test_get_available_browsers(browser: str) -> None:
marks=pytest.mark.skipif(sys.platform != "linux", reason="linux-only"),
id="firefox-linux",
),
pytest.param(
"chrome",
{
"display_name": "Google Chrome",
"path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
"version": ANY,
},
marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only"),
id="chrome-win32",
),
pytest.param(
"firefox",
{
"display_name": "Mozilla Firefox",
"path": r"C:\Program Files\Mozilla Firefox\firefox.exe",
"version": ANY,
},
marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only"),
id="firefox-win32",
),
pytest.param(
"msedge",
{
"display_name": "Microsoft Edge",
"path": r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
"version": ANY,
},
marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only"),
id="msedge-win32",
),
pytest.param(
"msie",
{
"display_name": "Internet Explorer",
"path": r"C:\Program Files\Internet Explorer\iexplore.exe",
"version": ANY,
},
marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only"),
id="msie-win32",
),
),
)
def test_get(browser: str, details: Dict) -> None:
Expand All @@ -109,6 +152,11 @@ def test_get(browser: str, details: Dict) -> None:
id="linux",
marks=pytest.mark.skipif(sys.platform != "linux", reason="linux-only"),
),
pytest.param(
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
id="windows",
marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only"),
),
),
)
@mock.patch.object(browsers, "_launch")
Expand Down

0 comments on commit c49ddea

Please sign in to comment.